@event4u/agent-config 2.2.2 → 2.4.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/commands/onboard.md +14 -9
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/skills/ai-council/SKILL.md +5 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
- package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +79 -0
- package/README.md +1 -8
- package/config/agent-settings.template.yml +5 -3
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -3
- package/docs/contracts/installed-tools-lockfile.md +142 -0
- package/docs/customization.md +23 -17
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
- package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +6 -4
- package/docs/installation.md +17 -2
- package/docs/migration/v1-to-v2.md +45 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/claude-desktop.md +107 -65
- 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 +30 -4
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/agent_settings.py +29 -7
- package/scripts/_lib/agents_overlay.py +15 -4
- package/scripts/_lib/claude_desktop_bundler.py +150 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_lock.py +37 -4
- package/scripts/_lib/installed_tools.py +189 -45
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/_lib/update_check.py +29 -5
- package/scripts/_lib/user_global_paths.py +249 -0
- package/scripts/agent-config +69 -0
- package/scripts/ai_council/__init__.py +4 -3
- package/scripts/ai_council/budget_guard.py +34 -4
- package/scripts/ai_council/bundler.py +2 -0
- package/scripts/ai_council/clients.py +28 -7
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1149 -53
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- package/scripts/skill_trigger_eval.py +13 -2
package/scripts/install.py
CHANGED
|
@@ -26,18 +26,32 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
28
|
import copy
|
|
29
|
+
import hashlib
|
|
29
30
|
import json
|
|
30
31
|
import os
|
|
31
32
|
import re
|
|
32
33
|
import shlex
|
|
34
|
+
import shutil
|
|
33
35
|
import subprocess
|
|
34
36
|
import sys
|
|
35
37
|
from pathlib import Path
|
|
38
|
+
from typing import Any, Optional
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from scripts._lib.json_pointers import build_merge_entries # noqa: PLC0415
|
|
42
|
+
except ImportError: # pragma: no cover — alt sys.path layout
|
|
43
|
+
from _lib.json_pointers import build_merge_entries # type: ignore[no-redef] # noqa: PLC0415
|
|
36
44
|
|
|
37
45
|
DEFAULT_PROFILE = "minimal"
|
|
38
46
|
SUPPORTED_PROFILES = ("minimal", "balanced", "full")
|
|
39
47
|
COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
|
|
40
48
|
|
|
49
|
+
# Env-var equivalent of --force for CI / scripted installs (P3.4).
|
|
50
|
+
# When set to "1" the install run treats every conflict as
|
|
51
|
+
# force-overwrite; never enabled by default to keep destructive writes
|
|
52
|
+
# explicit.
|
|
53
|
+
ALLOW_OVERWRITE_ENV = "AGENT_CONFIG_ALLOW_OVERWRITE"
|
|
54
|
+
|
|
41
55
|
SETTINGS_FILE = ".agent-settings.yml"
|
|
42
56
|
LEGACY_SETTINGS_FILE = ".agent-settings"
|
|
43
57
|
LEGACY_BACKUP_FILE = ".agent-settings.backup.key-value"
|
|
@@ -128,6 +142,224 @@ def detect_package_type_for_project(project_root: Path, package_root: Path) -> s
|
|
|
128
142
|
return detect_package_type(package_root)
|
|
129
143
|
|
|
130
144
|
|
|
145
|
+
# --- Conflict detection (P3.1 / P3.3) ---
|
|
146
|
+
|
|
147
|
+
class ConflictAbort(SystemExit):
|
|
148
|
+
"""Raised when a conflict resolution chose 'abort'.
|
|
149
|
+
|
|
150
|
+
Inherits ``SystemExit`` so an unhandled abort terminates the
|
|
151
|
+
install with a non-zero exit code without an opaque traceback.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, message: str):
|
|
155
|
+
super().__init__(1)
|
|
156
|
+
self.message = message
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ConflictPolicy:
|
|
160
|
+
"""Per-install conflict resolution policy (P3.1).
|
|
161
|
+
|
|
162
|
+
Aggregates the inputs the resolver needs to decide whether to
|
|
163
|
+
overwrite a target that exists on disk:
|
|
164
|
+
|
|
165
|
+
* ``force`` — true when ``--force`` was passed OR the
|
|
166
|
+
``AGENT_CONFIG_ALLOW_OVERWRITE=1`` env-var is set (P3.4).
|
|
167
|
+
* ``interactive`` — true when stdin AND stdout are TTYs; the
|
|
168
|
+
only context where the 3-option prompt is meaningful.
|
|
169
|
+
* ``known_paths`` — absolute path strings recorded as ours by
|
|
170
|
+
the project-scope manifest (``files[]`` entries). A target at a
|
|
171
|
+
known path is **not** a foreign collision — we own it and the
|
|
172
|
+
existing skip/force behaviour applies.
|
|
173
|
+
* ``known_pointers``— ``(file_label, json_pointer)`` pairs we
|
|
174
|
+
previously merged into shared JSON files (P3.3). A pointer in
|
|
175
|
+
this set is ours; one not in it that exists in the target is a
|
|
176
|
+
foreign merge collision.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
__slots__ = ("force", "interactive", "known_paths", "known_pointers")
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
force: bool,
|
|
185
|
+
interactive: bool,
|
|
186
|
+
known_paths: set[str],
|
|
187
|
+
known_pointers: set[tuple[str, str]],
|
|
188
|
+
) -> None:
|
|
189
|
+
self.force = force
|
|
190
|
+
self.interactive = interactive
|
|
191
|
+
self.known_paths = known_paths
|
|
192
|
+
self.known_pointers = known_pointers
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Module-level singleton: configured once in main() (after --force +
|
|
196
|
+
# env-var resolution), consulted by every writer. When ``None`` the
|
|
197
|
+
# install runs in **legacy mode**: writers honor their local ``force``
|
|
198
|
+
# flag and skip-otherwise, no foreign-pointer detection. Set only by
|
|
199
|
+
# :func:`main` after loading the manifest so test callers that exercise
|
|
200
|
+
# writers directly keep the pre-P3 contract.
|
|
201
|
+
_CONFLICT_POLICY: Optional[ConflictPolicy] = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _conflict_policy_active() -> bool:
|
|
205
|
+
return _CONFLICT_POLICY is not None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _get_conflict_policy() -> ConflictPolicy:
|
|
209
|
+
if _CONFLICT_POLICY is None:
|
|
210
|
+
# Legacy-mode fallback: no manifest loaded, no foreign detection
|
|
211
|
+
# surface. ``force=False`` here so the local ``force_hint`` from
|
|
212
|
+
# the caller is the only signal; ``known_*`` stay empty.
|
|
213
|
+
return ConflictPolicy(
|
|
214
|
+
force=False, interactive=False,
|
|
215
|
+
known_paths=set(), known_pointers=set(),
|
|
216
|
+
)
|
|
217
|
+
return _CONFLICT_POLICY
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _set_conflict_policy(policy: Optional[ConflictPolicy]) -> None:
|
|
221
|
+
global _CONFLICT_POLICY
|
|
222
|
+
_CONFLICT_POLICY = policy
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _allow_overwrite_env() -> bool:
|
|
226
|
+
return os.environ.get(ALLOW_OVERWRITE_ENV) == "1"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_interactive() -> bool:
|
|
230
|
+
try:
|
|
231
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
232
|
+
except (AttributeError, ValueError): # pragma: no cover — closed streams
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _load_conflict_policy(project_root: Path, force: bool) -> ConflictPolicy:
|
|
237
|
+
"""Build a :class:`ConflictPolicy` from the on-disk manifest.
|
|
238
|
+
|
|
239
|
+
Reads ``agents/installed-tools.lock`` once and folds every recorded
|
|
240
|
+
``files[].path`` into ``known_paths`` and every
|
|
241
|
+
``merged_keys[].{file, json_pointer}`` into ``known_pointers``. The
|
|
242
|
+
manifest is the only source of truth for "this is ours"; if it's
|
|
243
|
+
missing both sets stay empty and every existing target is treated
|
|
244
|
+
as foreign.
|
|
245
|
+
"""
|
|
246
|
+
known_paths: set[str] = set()
|
|
247
|
+
known_pointers: set[tuple[str, str]] = set()
|
|
248
|
+
try:
|
|
249
|
+
tools_mod = _load_installed_tools_module()
|
|
250
|
+
target = tools_mod.manifest_path(project_root)
|
|
251
|
+
existing = tools_mod.read_manifest(target) or {}
|
|
252
|
+
for tool in existing.get("tools", []) or []:
|
|
253
|
+
for entry in tool.get("files", []) or []:
|
|
254
|
+
path_val = entry.get("path")
|
|
255
|
+
if isinstance(path_val, str) and path_val:
|
|
256
|
+
# Manifest paths may be absolute (production writers
|
|
257
|
+
# use ``str(Path)`` for ``files[].path``) or relative
|
|
258
|
+
# (portable manifests). Writers always pass absolute
|
|
259
|
+
# ``Path`` objects to ``_resolve_file_conflict``, so
|
|
260
|
+
# normalise here against ``project_root`` to keep the
|
|
261
|
+
# known-path silent-skip branch reachable.
|
|
262
|
+
p = Path(path_val)
|
|
263
|
+
if not p.is_absolute():
|
|
264
|
+
p = (project_root / p).resolve()
|
|
265
|
+
known_paths.add(str(p))
|
|
266
|
+
for entry in tool.get("merged_keys", []) or []:
|
|
267
|
+
file_label = entry.get("file")
|
|
268
|
+
pointer = entry.get("json_pointer")
|
|
269
|
+
if isinstance(file_label, str) and isinstance(pointer, str):
|
|
270
|
+
known_pointers.add((file_label, pointer))
|
|
271
|
+
except Exception: # pragma: no cover — fail-open on corrupt manifest
|
|
272
|
+
# Don't block the install if the manifest is malformed; just
|
|
273
|
+
# report nothing as ours so foreign-file detection stays strict.
|
|
274
|
+
pass
|
|
275
|
+
return ConflictPolicy(
|
|
276
|
+
force=force or _allow_overwrite_env(),
|
|
277
|
+
interactive=_is_interactive(),
|
|
278
|
+
known_paths=known_paths,
|
|
279
|
+
known_pointers=known_pointers,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def prompt_file_conflict_choice(path: Path) -> str:
|
|
284
|
+
"""3-option resolution prompt for a foreign file at ``path``.
|
|
285
|
+
|
|
286
|
+
Returns ``"force"`` / ``"skip"`` / ``"abort"``. Mirrors
|
|
287
|
+
:func:`prompt_collision_choice` (loops on invalid input, aborts on
|
|
288
|
+
EOF or 3 invalid replies). Only called when the policy is
|
|
289
|
+
interactive AND ``--force`` was not specified.
|
|
290
|
+
"""
|
|
291
|
+
print()
|
|
292
|
+
warn(f"Foreign file at {path}")
|
|
293
|
+
info("This path exists but is not recorded as ours in the manifest.")
|
|
294
|
+
info("Choose how to handle the conflict:")
|
|
295
|
+
print(" 1) Force — overwrite the file with our content")
|
|
296
|
+
print(" 2) Skip — leave the file untouched, continue install")
|
|
297
|
+
print(" 3) Abort — stop the install, exit non-zero")
|
|
298
|
+
print()
|
|
299
|
+
attempts = 0
|
|
300
|
+
while attempts < 3:
|
|
301
|
+
try:
|
|
302
|
+
reply = _read_line("Choose [1/2/3]: ")
|
|
303
|
+
except EOFError:
|
|
304
|
+
fail(f"File-conflict prompt aborted (EOF on stdin) for {path}")
|
|
305
|
+
if reply in ("1", "force", "f"):
|
|
306
|
+
return "force"
|
|
307
|
+
if reply in ("2", "skip", "s"):
|
|
308
|
+
return "skip"
|
|
309
|
+
if reply in ("3", "abort", "a"):
|
|
310
|
+
return "abort"
|
|
311
|
+
attempts += 1
|
|
312
|
+
warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
|
|
313
|
+
fail(f"File-conflict prompt aborted (3 invalid replies) for {path}")
|
|
314
|
+
return "abort" # unreachable
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _resolve_file_conflict(target: Path, *, force_hint: bool) -> str:
|
|
318
|
+
"""Decide what to do when ``target`` already exists on disk.
|
|
319
|
+
|
|
320
|
+
Returns ``"write"`` (proceed with overwrite), ``"skip"`` (leave the
|
|
321
|
+
target alone), or raises :class:`ConflictAbort`. ``force_hint`` is
|
|
322
|
+
the caller's local ``force`` flag — typically the install-level
|
|
323
|
+
``--force``; we OR it with the global policy's ``force`` to honor
|
|
324
|
+
``AGENT_CONFIG_ALLOW_OVERWRITE=1`` in callers that have not yet
|
|
325
|
+
been refactored to read the policy directly.
|
|
326
|
+
|
|
327
|
+
Decision matrix:
|
|
328
|
+
|
|
329
|
+
* target does not exist → ``"write"``
|
|
330
|
+
* target IS in ``known_paths`` → ``"write"`` if force else ``"skip"``
|
|
331
|
+
(legacy behaviour — we own it, skip silently without --force)
|
|
332
|
+
* target NOT in ``known_paths`` (foreign):
|
|
333
|
+
* force → ``"write"`` (overwrite)
|
|
334
|
+
* interactive → prompt → translate to write/skip/abort
|
|
335
|
+
* non-interactive → raise ``ConflictAbort``
|
|
336
|
+
"""
|
|
337
|
+
if not target.exists():
|
|
338
|
+
return "write"
|
|
339
|
+
if not _conflict_policy_active():
|
|
340
|
+
# Legacy mode (no manifest loaded): preserve the pre-P3 contract
|
|
341
|
+
# — force overwrites, otherwise skip. No prompt, no abort.
|
|
342
|
+
return "write" if force_hint else "skip"
|
|
343
|
+
policy = _get_conflict_policy()
|
|
344
|
+
effective_force = force_hint or policy.force
|
|
345
|
+
if str(target) in policy.known_paths:
|
|
346
|
+
return "write" if effective_force else "skip"
|
|
347
|
+
if effective_force:
|
|
348
|
+
return "write"
|
|
349
|
+
if policy.interactive:
|
|
350
|
+
choice = prompt_file_conflict_choice(target)
|
|
351
|
+
if choice == "force":
|
|
352
|
+
return "write"
|
|
353
|
+
if choice == "skip":
|
|
354
|
+
return "skip"
|
|
355
|
+
raise ConflictAbort(f"User aborted on foreign file at {target}")
|
|
356
|
+
raise ConflictAbort(
|
|
357
|
+
f"Foreign file at {target}: refusing to overwrite. "
|
|
358
|
+
f"Re-run with --force or set {ALLOW_OVERWRITE_ENV}=1 to allow. "
|
|
359
|
+
f"Run `agent-config doctor` to inspect orphaned files first."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
131
363
|
# --- File utilities ---
|
|
132
364
|
|
|
133
365
|
def ensure_directory(path: Path) -> None:
|
|
@@ -167,25 +399,178 @@ def deep_merge(base: dict, overlay: dict) -> dict:
|
|
|
167
399
|
return result
|
|
168
400
|
|
|
169
401
|
|
|
170
|
-
def
|
|
402
|
+
def _pointer_target_exists(doc: dict, pointer: str) -> bool:
|
|
403
|
+
"""Return True when ``pointer`` resolves to an existing key in ``doc``.
|
|
404
|
+
|
|
405
|
+
Walks the RFC-6901 segments without descending into lists (per the
|
|
406
|
+
array-index ban in :mod:`scripts._lib.json_pointers`). Missing
|
|
407
|
+
intermediate segments short-circuit to False.
|
|
408
|
+
"""
|
|
409
|
+
if not pointer.startswith("/"):
|
|
410
|
+
return False
|
|
411
|
+
cursor: Any = doc
|
|
412
|
+
segments = pointer.split("/")[1:]
|
|
413
|
+
segments = [s.replace("~1", "/").replace("~0", "~") for s in segments]
|
|
414
|
+
for seg in segments[:-1]:
|
|
415
|
+
if not isinstance(cursor, dict) or seg not in cursor:
|
|
416
|
+
return False
|
|
417
|
+
cursor = cursor[seg]
|
|
418
|
+
if not isinstance(cursor, dict):
|
|
419
|
+
return False
|
|
420
|
+
return segments[-1] in cursor
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _detect_foreign_pointers(
|
|
424
|
+
existing: dict,
|
|
425
|
+
overlay_entries: list[dict[str, Any]],
|
|
426
|
+
label: str,
|
|
427
|
+
policy: ConflictPolicy,
|
|
428
|
+
) -> list[str]:
|
|
429
|
+
"""Return overlay pointers that exist in ``existing`` but aren't ours.
|
|
430
|
+
|
|
431
|
+
P3.3 — pointer-level foreign-merge detection. A pointer is foreign
|
|
432
|
+
when it would overwrite a value already on disk that the manifest
|
|
433
|
+
does NOT record as ours (``(label, pointer) not in known_pointers``).
|
|
434
|
+
Returns the list of foreign pointer strings (sorted, deduped) for
|
|
435
|
+
use in the conflict-resolution prompt. In legacy mode (no manifest
|
|
436
|
+
loaded) the function returns an empty list so callers fall back to
|
|
437
|
+
the pre-P3 update flow.
|
|
438
|
+
"""
|
|
439
|
+
if not _conflict_policy_active():
|
|
440
|
+
return []
|
|
441
|
+
foreign: list[str] = []
|
|
442
|
+
seen: set[str] = set()
|
|
443
|
+
for entry in overlay_entries:
|
|
444
|
+
pointer = entry.get("json_pointer")
|
|
445
|
+
if not isinstance(pointer, str) or pointer in seen:
|
|
446
|
+
continue
|
|
447
|
+
seen.add(pointer)
|
|
448
|
+
if not _pointer_target_exists(existing, pointer):
|
|
449
|
+
continue
|
|
450
|
+
if (label, pointer) in policy.known_pointers:
|
|
451
|
+
continue
|
|
452
|
+
foreign.append(pointer)
|
|
453
|
+
foreign.sort()
|
|
454
|
+
return foreign
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def prompt_json_conflict_choice(path: Path, foreign: list[str]) -> str:
|
|
458
|
+
"""3-option resolution prompt for foreign JSON pointers at ``path``.
|
|
459
|
+
|
|
460
|
+
Returns ``"force"`` / ``"skip"`` / ``"abort"``. Shows the foreign
|
|
461
|
+
pointer list so the user knows what will be overwritten.
|
|
462
|
+
"""
|
|
463
|
+
print()
|
|
464
|
+
warn(f"Foreign JSON keys at {path}")
|
|
465
|
+
info("The following pointers exist in the file but are not recorded as ours:")
|
|
466
|
+
for pointer in foreign[:10]:
|
|
467
|
+
print(f" {pointer}")
|
|
468
|
+
if len(foreign) > 10:
|
|
469
|
+
print(f" ... and {len(foreign) - 10} more")
|
|
470
|
+
info("Choose how to handle the conflict:")
|
|
471
|
+
print(" 1) Force — overwrite the listed pointers with our values")
|
|
472
|
+
print(" 2) Skip — leave the file untouched, continue install")
|
|
473
|
+
print(" 3) Abort — stop the install, exit non-zero")
|
|
474
|
+
print()
|
|
475
|
+
attempts = 0
|
|
476
|
+
while attempts < 3:
|
|
477
|
+
try:
|
|
478
|
+
reply = _read_line("Choose [1/2/3]: ")
|
|
479
|
+
except EOFError:
|
|
480
|
+
fail(f"JSON-conflict prompt aborted (EOF on stdin) for {path}")
|
|
481
|
+
if reply in ("1", "force", "f"):
|
|
482
|
+
return "force"
|
|
483
|
+
if reply in ("2", "skip", "s"):
|
|
484
|
+
return "skip"
|
|
485
|
+
if reply in ("3", "abort", "a"):
|
|
486
|
+
return "abort"
|
|
487
|
+
attempts += 1
|
|
488
|
+
warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
|
|
489
|
+
fail(f"JSON-conflict prompt aborted (3 invalid replies) for {path}")
|
|
490
|
+
return "abort" # unreachable
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _resolve_json_conflict(
|
|
494
|
+
path: Path, label: str, foreign: list[str], *, force_hint: bool,
|
|
495
|
+
) -> str:
|
|
496
|
+
"""Decide what to do when ``label`` has foreign pointers (P3.3).
|
|
497
|
+
|
|
498
|
+
Returns ``"write"`` or ``"skip"``; raises :class:`ConflictAbort`.
|
|
499
|
+
Same resolution matrix as :func:`_resolve_file_conflict` but with a
|
|
500
|
+
pointer-aware prompt.
|
|
501
|
+
"""
|
|
502
|
+
policy = _get_conflict_policy()
|
|
503
|
+
effective_force = force_hint or policy.force
|
|
504
|
+
if effective_force:
|
|
505
|
+
return "write"
|
|
506
|
+
if policy.interactive:
|
|
507
|
+
choice = prompt_json_conflict_choice(path, foreign)
|
|
508
|
+
if choice == "force":
|
|
509
|
+
return "write"
|
|
510
|
+
if choice == "skip":
|
|
511
|
+
return "skip"
|
|
512
|
+
raise ConflictAbort(f"User aborted on foreign JSON pointers at {path}")
|
|
513
|
+
raise ConflictAbort(
|
|
514
|
+
f"Foreign JSON pointers at {path}: refusing to overwrite "
|
|
515
|
+
f"({len(foreign)} key(s)). Re-run with --force or set "
|
|
516
|
+
f"{ALLOW_OVERWRITE_ENV}=1 to allow. "
|
|
517
|
+
f"Run `agent-config doctor` to inspect orphaned pointers first."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def merge_json_file(
|
|
522
|
+
path: Path, new_data: dict, force: bool, label: str,
|
|
523
|
+
) -> list[dict[str, Any]]:
|
|
524
|
+
"""Merge ``new_data`` into ``path``; return v2 ``merged_keys[]`` entries.
|
|
525
|
+
|
|
526
|
+
P1.5 + P3.2 + P3.3 of road-to-multi-package-coexistence: every JSON
|
|
527
|
+
pointer the install writes lands in the manifest so uninstall can
|
|
528
|
+
subtract it cleanly. The merge uses leaf-level pointer-replace
|
|
529
|
+
semantics (``deep_merge`` recurses into dicts, replaces at leaves)
|
|
530
|
+
so sibling keys owned by neighbour packages survive. Before any
|
|
531
|
+
write that would overwrite a pre-existing pointer NOT recorded as
|
|
532
|
+
ours, the conflict policy is consulted (force / interactive prompt
|
|
533
|
+
/ non-interactive abort).
|
|
534
|
+
|
|
535
|
+
Returns the v2 entries on a successful create / update; returns
|
|
536
|
+
``[]`` when the file was already in sync or the update was
|
|
537
|
+
suppressed without ``--force`` / on a skip choice.
|
|
538
|
+
"""
|
|
539
|
+
new_entries = build_merge_entries(label, new_data)
|
|
540
|
+
|
|
171
541
|
if not path.exists():
|
|
172
542
|
write_json_file(path, new_data)
|
|
173
543
|
success(f"{label} created")
|
|
174
|
-
return
|
|
544
|
+
return new_entries
|
|
175
545
|
|
|
176
546
|
existing = read_json_file(path)
|
|
177
547
|
merged = deep_merge(existing, new_data)
|
|
178
548
|
|
|
179
549
|
if merged == existing:
|
|
180
550
|
skip(f"{label} already configured")
|
|
181
|
-
return
|
|
182
|
-
|
|
183
|
-
|
|
551
|
+
return new_entries
|
|
552
|
+
|
|
553
|
+
policy = _get_conflict_policy()
|
|
554
|
+
foreign = _detect_foreign_pointers(existing, new_entries, label, policy)
|
|
555
|
+
|
|
556
|
+
if foreign:
|
|
557
|
+
# Foreign-pointer collision: ask the policy. On "write" we fall
|
|
558
|
+
# through and let deep_merge produce the leaf-level pointer-
|
|
559
|
+
# replace; on "skip" we bail without changing the file.
|
|
560
|
+
decision = _resolve_json_conflict(path, label, foreign, force_hint=force)
|
|
561
|
+
if decision == "skip":
|
|
562
|
+
skip(f"{label} has foreign keys, skipped")
|
|
563
|
+
return []
|
|
564
|
+
elif not (force or policy.force):
|
|
565
|
+
# No foreign collision but file needs an update — preserve the
|
|
566
|
+
# legacy "needs --force" contract so existing test expectations
|
|
567
|
+
# and the project-bridge flow stay intact.
|
|
184
568
|
skip(f"{label} exists, needs update (use --force)")
|
|
185
|
-
return
|
|
569
|
+
return []
|
|
186
570
|
|
|
187
571
|
write_json_file(path, merged)
|
|
188
572
|
success(f"{label} updated")
|
|
573
|
+
return new_entries
|
|
189
574
|
|
|
190
575
|
|
|
191
576
|
# --- Legacy settings migration ---
|
|
@@ -442,12 +827,16 @@ def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) ->
|
|
|
442
827
|
plugin_path = plugin_paths.get(package_type, "./plugin/agent-config")
|
|
443
828
|
|
|
444
829
|
bridge = {"chat.pluginLocations": {plugin_path: True}}
|
|
830
|
+
# Substrate bridge — not tracked in the manifest, so merged_keys
|
|
831
|
+
# are computed but discarded.
|
|
445
832
|
merge_json_file(project_root / ".vscode" / "settings.json", bridge, force, ".vscode/settings.json")
|
|
446
833
|
|
|
447
834
|
|
|
448
|
-
def ensure_augment_bridge(project_root: Path, force: bool) ->
|
|
835
|
+
def ensure_augment_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
449
836
|
bridge = {"enabledPlugins": {"agent-config@event4u": True}}
|
|
450
|
-
merge_json_file(
|
|
837
|
+
return merge_json_file(
|
|
838
|
+
project_root / ".augment" / "settings.json", bridge, force, ".augment/settings.json",
|
|
839
|
+
)
|
|
451
840
|
|
|
452
841
|
|
|
453
842
|
# Augment lifecycle hooks live at user scope (~/.augment/settings.json) per
|
|
@@ -520,7 +909,7 @@ def _remove_legacy_augment_trampolines() -> None:
|
|
|
520
909
|
pass
|
|
521
910
|
|
|
522
911
|
|
|
523
|
-
def ensure_augment_user_hooks(package_root: Path, force: bool) ->
|
|
912
|
+
def ensure_augment_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
524
913
|
"""Deploy the Augment universal-dispatcher trampoline at user scope.
|
|
525
914
|
|
|
526
915
|
Phase 7.3 (hook-architecture-v1.md): one trampoline replaces the
|
|
@@ -543,7 +932,7 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
543
932
|
"""
|
|
544
933
|
dst = _deploy_augment_trampoline(package_root, AUGMENT_DISPATCHER_TRAMPOLINE, force)
|
|
545
934
|
if dst is None:
|
|
546
|
-
return
|
|
935
|
+
return []
|
|
547
936
|
|
|
548
937
|
_remove_legacy_augment_trampolines()
|
|
549
938
|
|
|
@@ -556,7 +945,7 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
556
945
|
per_event.setdefault(native, []).append(entry)
|
|
557
946
|
|
|
558
947
|
settings_patch: dict = {"hooks": per_event}
|
|
559
|
-
merge_json_file(
|
|
948
|
+
return merge_json_file(
|
|
560
949
|
AUGMENT_USER_DIR / "settings.json",
|
|
561
950
|
settings_patch,
|
|
562
951
|
force,
|
|
@@ -597,7 +986,7 @@ def _claude_dispatch_block(ac_event: str, native: str) -> dict:
|
|
|
597
986
|
}
|
|
598
987
|
|
|
599
988
|
|
|
600
|
-
def ensure_claude_bridge(project_root: Path, force: bool) ->
|
|
989
|
+
def ensure_claude_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
601
990
|
"""Deploy .claude/settings.json with plugin enablement and the Phase 7
|
|
602
991
|
universal dispatcher hooks.
|
|
603
992
|
|
|
@@ -619,7 +1008,9 @@ def ensure_claude_bridge(project_root: Path, force: bool) -> None:
|
|
|
619
1008
|
"enabledPlugins": {"agent-conf@event4u": True},
|
|
620
1009
|
"hooks": per_event,
|
|
621
1010
|
}
|
|
622
|
-
merge_json_file(
|
|
1011
|
+
return merge_json_file(
|
|
1012
|
+
project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json",
|
|
1013
|
+
)
|
|
623
1014
|
|
|
624
1015
|
|
|
625
1016
|
# Cursor lifecycle events → agent-config event vocabulary.
|
|
@@ -651,7 +1042,7 @@ def _cursor_dispatch_command(ac_event: str, native: str) -> str:
|
|
|
651
1042
|
)
|
|
652
1043
|
|
|
653
1044
|
|
|
654
|
-
def ensure_cursor_bridge(project_root: Path, force: bool) ->
|
|
1045
|
+
def ensure_cursor_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
655
1046
|
"""Deploy `.cursor/hooks.json` (project scope) with the Phase 7
|
|
656
1047
|
universal dispatcher hooks.
|
|
657
1048
|
|
|
@@ -669,7 +1060,9 @@ def ensure_cursor_bridge(project_root: Path, force: bool) -> None:
|
|
|
669
1060
|
)
|
|
670
1061
|
|
|
671
1062
|
bridge = {"version": 1, "hooks": hooks}
|
|
672
|
-
merge_json_file(
|
|
1063
|
+
return merge_json_file(
|
|
1064
|
+
project_root / ".cursor" / "hooks.json", bridge, force, ".cursor/hooks.json",
|
|
1065
|
+
)
|
|
673
1066
|
|
|
674
1067
|
|
|
675
1068
|
# Cursor user-scope hooks fire across every project the developer opens
|
|
@@ -682,7 +1075,7 @@ CURSOR_USER_HOOKS_DIR = CURSOR_USER_DIR / "hooks"
|
|
|
682
1075
|
CURSOR_DISPATCHER_TRAMPOLINE = "cursor-dispatcher.sh"
|
|
683
1076
|
|
|
684
1077
|
|
|
685
|
-
def ensure_cursor_user_hooks(package_root: Path, force: bool) ->
|
|
1078
|
+
def ensure_cursor_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
686
1079
|
"""Deploy the Cursor universal-dispatcher trampoline at user scope.
|
|
687
1080
|
|
|
688
1081
|
Phase 7.5 (hook-architecture-v1.md): mirrors ensure_augment_user_hooks
|
|
@@ -697,7 +1090,7 @@ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
697
1090
|
src = package_root / "scripts" / "hooks" / CURSOR_DISPATCHER_TRAMPOLINE
|
|
698
1091
|
if not src.exists():
|
|
699
1092
|
skip(f"cursor trampoline missing in package: {src}")
|
|
700
|
-
return
|
|
1093
|
+
return []
|
|
701
1094
|
|
|
702
1095
|
CURSOR_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
703
1096
|
dst = CURSOR_USER_HOOKS_DIR / CURSOR_DISPATCHER_TRAMPOLINE
|
|
@@ -716,7 +1109,7 @@ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
716
1109
|
)
|
|
717
1110
|
|
|
718
1111
|
settings_patch: dict = {"version": 1, "hooks": hooks}
|
|
719
|
-
merge_json_file(
|
|
1112
|
+
return merge_json_file(
|
|
720
1113
|
CURSOR_USER_DIR / "hooks.json",
|
|
721
1114
|
settings_patch,
|
|
722
1115
|
force,
|
|
@@ -882,7 +1275,7 @@ def _windsurf_dispatch_command(ac_event: str, native: str) -> str:
|
|
|
882
1275
|
)
|
|
883
1276
|
|
|
884
1277
|
|
|
885
|
-
def ensure_windsurf_bridge(project_root: Path, force: bool) ->
|
|
1278
|
+
def ensure_windsurf_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
886
1279
|
"""Deploy `.windsurf/hooks.json` (project scope) with the Phase 7
|
|
887
1280
|
universal dispatcher hooks.
|
|
888
1281
|
|
|
@@ -902,7 +1295,7 @@ def ensure_windsurf_bridge(project_root: Path, force: bool) -> None:
|
|
|
902
1295
|
})
|
|
903
1296
|
|
|
904
1297
|
bridge = {"hooks": hooks}
|
|
905
|
-
merge_json_file(
|
|
1298
|
+
return merge_json_file(
|
|
906
1299
|
project_root / ".windsurf" / "hooks.json",
|
|
907
1300
|
bridge,
|
|
908
1301
|
force,
|
|
@@ -921,7 +1314,7 @@ WINDSURF_USER_HOOKS_DIR = WINDSURF_USER_DIR / "hooks"
|
|
|
921
1314
|
WINDSURF_DISPATCHER_TRAMPOLINE = "windsurf-dispatcher.sh"
|
|
922
1315
|
|
|
923
1316
|
|
|
924
|
-
def ensure_windsurf_user_hooks(package_root: Path, force: bool) ->
|
|
1317
|
+
def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
925
1318
|
"""Deploy the Windsurf universal-dispatcher trampoline at user scope.
|
|
926
1319
|
|
|
927
1320
|
Phase 7.7 (hook-architecture-v1.md): mirrors ensure_cursor_user_hooks
|
|
@@ -936,7 +1329,7 @@ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
936
1329
|
src = package_root / "scripts" / "hooks" / WINDSURF_DISPATCHER_TRAMPOLINE
|
|
937
1330
|
if not src.exists():
|
|
938
1331
|
skip(f"windsurf trampoline missing in package: {src}")
|
|
939
|
-
return
|
|
1332
|
+
return []
|
|
940
1333
|
|
|
941
1334
|
WINDSURF_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
942
1335
|
dst = WINDSURF_USER_HOOKS_DIR / WINDSURF_DISPATCHER_TRAMPOLINE
|
|
@@ -956,7 +1349,7 @@ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
956
1349
|
})
|
|
957
1350
|
|
|
958
1351
|
settings_patch: dict = {"hooks": hooks}
|
|
959
|
-
merge_json_file(
|
|
1352
|
+
return merge_json_file(
|
|
960
1353
|
WINDSURF_USER_DIR / "hooks.json",
|
|
961
1354
|
settings_patch,
|
|
962
1355
|
force,
|
|
@@ -1014,7 +1407,7 @@ def _gemini_hooks_dict(command_factory) -> dict[str, list]:
|
|
|
1014
1407
|
return out
|
|
1015
1408
|
|
|
1016
1409
|
|
|
1017
|
-
def ensure_gemini_bridge(project_root: Path, force: bool) ->
|
|
1410
|
+
def ensure_gemini_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
1018
1411
|
"""Deploy `.gemini/settings.json` (project scope) with the Phase 7
|
|
1019
1412
|
universal dispatcher hooks.
|
|
1020
1413
|
|
|
@@ -1025,7 +1418,7 @@ def ensure_gemini_bridge(project_root: Path, force: bool) -> None:
|
|
|
1025
1418
|
rather than appending duplicates.
|
|
1026
1419
|
"""
|
|
1027
1420
|
bridge = {"hooks": _gemini_hooks_dict(_gemini_dispatch_command)}
|
|
1028
|
-
merge_json_file(
|
|
1421
|
+
return merge_json_file(
|
|
1029
1422
|
project_root / ".gemini" / "settings.json",
|
|
1030
1423
|
bridge,
|
|
1031
1424
|
force,
|
|
@@ -1043,7 +1436,7 @@ GEMINI_USER_HOOKS_DIR = GEMINI_USER_DIR / "hooks"
|
|
|
1043
1436
|
GEMINI_DISPATCHER_TRAMPOLINE = "gemini-dispatcher.sh"
|
|
1044
1437
|
|
|
1045
1438
|
|
|
1046
|
-
def ensure_gemini_user_hooks(package_root: Path, force: bool) ->
|
|
1439
|
+
def ensure_gemini_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
|
|
1047
1440
|
"""Deploy the Gemini universal-dispatcher trampoline at user scope.
|
|
1048
1441
|
|
|
1049
1442
|
Phase 7.8 (hook-architecture-v1.md): mirrors ensure_windsurf_user_hooks
|
|
@@ -1058,7 +1451,7 @@ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
1058
1451
|
src = package_root / "scripts" / "hooks" / GEMINI_DISPATCHER_TRAMPOLINE
|
|
1059
1452
|
if not src.exists():
|
|
1060
1453
|
skip(f"gemini trampoline missing in package: {src}")
|
|
1061
|
-
return
|
|
1454
|
+
return []
|
|
1062
1455
|
|
|
1063
1456
|
GEMINI_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
1064
1457
|
dst = GEMINI_USER_HOOKS_DIR / GEMINI_DISPATCHER_TRAMPOLINE
|
|
@@ -1075,7 +1468,7 @@ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
|
|
|
1075
1468
|
lambda ac_event, native: f"{dst} {ac_event} {native}",
|
|
1076
1469
|
),
|
|
1077
1470
|
}
|
|
1078
|
-
merge_json_file(
|
|
1471
|
+
return merge_json_file(
|
|
1079
1472
|
GEMINI_USER_DIR / "settings.json",
|
|
1080
1473
|
settings_patch,
|
|
1081
1474
|
force,
|
|
@@ -1120,6 +1513,19 @@ Roo Code reads `.roo/rules/*.md` as system-level instructions. The
|
|
|
1120
1513
|
canonical rule and skill source lives under `.augment/` (Augment
|
|
1121
1514
|
portability mirror — see `AGENTS.md` for orientation).
|
|
1122
1515
|
|
|
1516
|
+
## How to use
|
|
1517
|
+
|
|
1518
|
+
- These rules load automatically on every Roo Code session — no
|
|
1519
|
+
manual action required.
|
|
1520
|
+
- Switch Roo Code modes (Architect / Code / Ask / Debug / Custom)
|
|
1521
|
+
via the mode switcher to invoke different cognition profiles;
|
|
1522
|
+
every mode still sees these rules.
|
|
1523
|
+
- Slash commands and skills live under `.augment/commands/` and
|
|
1524
|
+
`.augment/skills/`. Roo Code does not register them natively —
|
|
1525
|
+
invoke them by name in chat (e.g. *"run the create-pr command"*).
|
|
1526
|
+
|
|
1527
|
+
See `docs/setup/per-ide/roocode.md` for the full activation guide.
|
|
1528
|
+
|
|
1123
1529
|
Run `./agent-config --help` for available commands.
|
|
1124
1530
|
"""
|
|
1125
1531
|
|
|
@@ -1296,6 +1702,19 @@ Kilo Code auto-discovers `.kilocode/rules/*.md` as system-level rules
|
|
|
1296
1702
|
per session. The canonical rule and skill source lives under
|
|
1297
1703
|
`.augment/` (Augment portability mirror — see `AGENTS.md` for
|
|
1298
1704
|
orientation).
|
|
1705
|
+
|
|
1706
|
+
## How to use
|
|
1707
|
+
|
|
1708
|
+
- These rules load automatically on every Kilo Code session — no
|
|
1709
|
+
manual action required.
|
|
1710
|
+
- Switch Kilo Code modes (Architect / Code / Ask / Debug /
|
|
1711
|
+
Orchestrator) via the mode switcher to invoke different
|
|
1712
|
+
cognition profiles; every mode still sees these rules.
|
|
1713
|
+
- Slash commands and skills live under `.augment/commands/` and
|
|
1714
|
+
`.augment/skills/`. Kilo Code does not register them natively —
|
|
1715
|
+
invoke them by name in chat (e.g. *"run the create-pr command"*).
|
|
1716
|
+
|
|
1717
|
+
See `docs/setup/per-ide/kilocode.md` for the full activation guide.
|
|
1299
1718
|
"""
|
|
1300
1719
|
|
|
1301
1720
|
|
|
@@ -1406,6 +1825,20 @@ This file marks the project as an `event4u/agent-config` consumer.
|
|
|
1406
1825
|
Kiro auto-discovers `.kiro/steering/*.md` as steering documents per
|
|
1407
1826
|
session. The canonical rule and skill source lives under `.augment/`
|
|
1408
1827
|
(Augment portability mirror — see `AGENTS.md` for orientation).
|
|
1828
|
+
|
|
1829
|
+
## How to use
|
|
1830
|
+
|
|
1831
|
+
- Steering documents load automatically on every Kiro session — no
|
|
1832
|
+
manual action required.
|
|
1833
|
+
- For structured, plan-first work, use Kiro's **Spec** workflow
|
|
1834
|
+
(the agent produces a spec → tasks → implementation under your
|
|
1835
|
+
review). For free-form work, use **Vibe**. Both honor these
|
|
1836
|
+
steering documents.
|
|
1837
|
+
- Slash commands and skills live under `.augment/commands/` and
|
|
1838
|
+
`.augment/skills/`. Kiro does not register them natively —
|
|
1839
|
+
invoke them by name in chat (e.g. *"run the create-pr command"*).
|
|
1840
|
+
|
|
1841
|
+
See `docs/setup/per-ide/kiro.md` for the full activation guide.
|
|
1409
1842
|
"""
|
|
1410
1843
|
|
|
1411
1844
|
|
|
@@ -1556,6 +1989,16 @@ USER_SCOPE_PATHS = {
|
|
|
1556
1989
|
"zed": "~/.config/zed/",
|
|
1557
1990
|
"jetbrains": "~/.config/JetBrains/",
|
|
1558
1991
|
"kiro": "~/.kiro/",
|
|
1992
|
+
# Phase 2.4 expansion — anchors lifted from
|
|
1993
|
+
# nextlevelbuilder/ui-ux-pro-max-skill (cli/assets/templates/platforms/*.json)
|
|
1994
|
+
# so `--global` covers every tool that ships a markdown-skills convention.
|
|
1995
|
+
"qoder": "~/.qoder/",
|
|
1996
|
+
"opencode": "~/.opencode/",
|
|
1997
|
+
"trae": "~/.trae/",
|
|
1998
|
+
"antigravity": "~/.agents/",
|
|
1999
|
+
"codebuddy": "~/.codebuddy/",
|
|
2000
|
+
"droid": "~/.factory/",
|
|
2001
|
+
"warp": "~/.warp/",
|
|
1559
2002
|
}
|
|
1560
2003
|
|
|
1561
2004
|
|
|
@@ -1583,12 +2026,24 @@ SCOPE_SUPPORT = {
|
|
|
1583
2026
|
"augment": "both",
|
|
1584
2027
|
"aider": "both",
|
|
1585
2028
|
"codex": "both",
|
|
1586
|
-
|
|
2029
|
+
# Phase 2.4: roocode / kilocode lifted to "both" — global deploys
|
|
2030
|
+
# write to `~/.roo/skills/` and `~/.kilocode/skills/` matching the
|
|
2031
|
+
# nextlevelbuilder/ui-ux-pro-max-skill anchors.
|
|
2032
|
+
"roocode": "both",
|
|
1587
2033
|
"continue": "both",
|
|
1588
|
-
"kilocode": "
|
|
2034
|
+
"kilocode": "both",
|
|
1589
2035
|
"zed": "both",
|
|
1590
2036
|
"jetbrains": "global",
|
|
1591
2037
|
"kiro": "both",
|
|
2038
|
+
# Phase 2.4 expansion — global-only for new anchors; project bridges
|
|
2039
|
+
# are not yet implemented for these IDs.
|
|
2040
|
+
"qoder": "global",
|
|
2041
|
+
"opencode": "global",
|
|
2042
|
+
"trae": "global",
|
|
2043
|
+
"antigravity": "global",
|
|
2044
|
+
"codebuddy": "global",
|
|
2045
|
+
"droid": "global",
|
|
2046
|
+
"warp": "global",
|
|
1592
2047
|
}
|
|
1593
2048
|
|
|
1594
2049
|
|
|
@@ -1618,6 +2073,120 @@ PROJECT_BRIDGE_MARKERS = {
|
|
|
1618
2073
|
}
|
|
1619
2074
|
|
|
1620
2075
|
|
|
2076
|
+
# Per-tool content deployment plan for `--global` installs. Each entry is a
|
|
2077
|
+
# list of ``(package_src_relative, dest_subpath)`` tuples. ``package_src_relative``
|
|
2078
|
+
# resolves against the agent-config package root; ``dest_subpath`` is appended
|
|
2079
|
+
# to ``USER_SCOPE_PATHS[tool_id]`` (expanded). Symlinks in the source are
|
|
2080
|
+
# dereferenced so the user-scope copy stays valid after npx cache eviction
|
|
2081
|
+
# (Council Round 3 Q1 rejected cross-scope symlinks).
|
|
2082
|
+
#
|
|
2083
|
+
# Tools absent from this map have no deployable content yet in global scope:
|
|
2084
|
+
# - ``copilot`` has no user-scope convention (rules live in
|
|
2085
|
+
# ``.github/copilot-instructions.md`` per project); users export per-project
|
|
2086
|
+
# via ``agent-config export --tool=copilot``.
|
|
2087
|
+
# - ``aider`` config is a single YAML file (``~/.aider.conf.yml``), not a
|
|
2088
|
+
# directory; --global prints a hint rather than synthesizing a file.
|
|
2089
|
+
# - ``zed`` / ``jetbrains`` have no markdown-skills convention; --global
|
|
2090
|
+
# prints a hint.
|
|
2091
|
+
# - ``claude-desktop`` is a marker-only deployment, handled in
|
|
2092
|
+
# ``_write_claude_desktop_marker`` rather than via this map.
|
|
2093
|
+
#
|
|
2094
|
+
# Tools that follow the markdown-skills convention (anchors lifted from
|
|
2095
|
+
# nextlevelbuilder/ui-ux-pro-max-skill) deploy ``.claude/skills`` —
|
|
2096
|
+
# the universal Anthropic-shaped skill bundle — into ``<anchor>/skills/``
|
|
2097
|
+
# (or ``<anchor>/steering/`` for kiro). ``.claude/rules`` is also copied
|
|
2098
|
+
# where the destination is a true rules-aware tool root.
|
|
2099
|
+
_CLAUDE_SKILL_BUNDLE: list[tuple[str, str]] = [
|
|
2100
|
+
(".claude/rules", "rules"),
|
|
2101
|
+
(".claude/skills", "skills"),
|
|
2102
|
+
(".claude/personas", "personas"),
|
|
2103
|
+
]
|
|
2104
|
+
GLOBAL_DEPLOY_SOURCES: dict[str, list[tuple[str, str]]] = {
|
|
2105
|
+
"claude-code": _CLAUDE_SKILL_BUNDLE,
|
|
2106
|
+
"augment": [
|
|
2107
|
+
(".augment/rules", "rules"),
|
|
2108
|
+
(".augment/skills", "skills"),
|
|
2109
|
+
(".augment/commands", "commands"),
|
|
2110
|
+
(".augment/contexts", "contexts"),
|
|
2111
|
+
(".augment/personas", "personas"),
|
|
2112
|
+
(".augment/templates", "templates"),
|
|
2113
|
+
],
|
|
2114
|
+
"cursor": [
|
|
2115
|
+
(".cursor/rules", "rules"),
|
|
2116
|
+
(".cursor/commands", "commands"),
|
|
2117
|
+
(".cursor/personas", "personas"),
|
|
2118
|
+
],
|
|
2119
|
+
"windsurf": [
|
|
2120
|
+
(".windsurf/rules", "rules"),
|
|
2121
|
+
(".windsurf/workflows", "workflows"),
|
|
2122
|
+
],
|
|
2123
|
+
"cline": [
|
|
2124
|
+
(".clinerules", ""),
|
|
2125
|
+
],
|
|
2126
|
+
# Markdown-skills tools — mirror the universal skill bundle into the
|
|
2127
|
+
# tool-specific anchor. Subpath matches the reference repo's
|
|
2128
|
+
# platform JSON `folderStructure.skillPath` (with the skill-name
|
|
2129
|
+
# tail stripped — we deploy the entire bundle, not a single skill).
|
|
2130
|
+
"gemini-cli": _CLAUDE_SKILL_BUNDLE,
|
|
2131
|
+
"codex": _CLAUDE_SKILL_BUNDLE,
|
|
2132
|
+
"continue": _CLAUDE_SKILL_BUNDLE,
|
|
2133
|
+
"roocode": _CLAUDE_SKILL_BUNDLE,
|
|
2134
|
+
"kilocode": _CLAUDE_SKILL_BUNDLE,
|
|
2135
|
+
"qoder": _CLAUDE_SKILL_BUNDLE,
|
|
2136
|
+
"opencode": _CLAUDE_SKILL_BUNDLE,
|
|
2137
|
+
"trae": _CLAUDE_SKILL_BUNDLE,
|
|
2138
|
+
"antigravity": _CLAUDE_SKILL_BUNDLE,
|
|
2139
|
+
"codebuddy": _CLAUDE_SKILL_BUNDLE,
|
|
2140
|
+
"droid": _CLAUDE_SKILL_BUNDLE,
|
|
2141
|
+
"warp": _CLAUDE_SKILL_BUNDLE,
|
|
2142
|
+
# Kiro reads from `steering/` not `skills/` (per
|
|
2143
|
+
# platforms/kiro.json#folderStructure.skillPath).
|
|
2144
|
+
"kiro": [
|
|
2145
|
+
(".claude/rules", "rules"),
|
|
2146
|
+
(".claude/skills", "steering"),
|
|
2147
|
+
(".claude/personas", "personas"),
|
|
2148
|
+
],
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
# Marker body written to the Claude Desktop user-scope directory. Claude
|
|
2153
|
+
# Desktop has no rules/skills filesystem convention; the marker advertises
|
|
2154
|
+
# the agent-config install for downstream tooling and gives users a stable
|
|
2155
|
+
# pointer to the lockfile.
|
|
2156
|
+
_CLAUDE_DESKTOP_MARKER_TEMPLATE = """\
|
|
2157
|
+
# agent-config — Claude Desktop marker
|
|
2158
|
+
|
|
2159
|
+
Installed by `@event4u/agent-config` (user scope, ADR-007).
|
|
2160
|
+
|
|
2161
|
+
- Lockfile: `{lockfile}`
|
|
2162
|
+
- Anchor: `{anchor}`
|
|
2163
|
+
- Skill bundles: `{bundles_dir}` ({bundle_count} ZIPs)
|
|
2164
|
+
|
|
2165
|
+
## Import skills into Claude Desktop
|
|
2166
|
+
|
|
2167
|
+
Claude Desktop has no filesystem skill-discovery convention — skills are
|
|
2168
|
+
imported manually via the Customize → Skills UI.
|
|
2169
|
+
|
|
2170
|
+
1. Open Claude Desktop → **Settings → Customize → Skills**.
|
|
2171
|
+
2. Click the **Upload skill** button.
|
|
2172
|
+
3. Browse to `{bundles_dir}` and pick the `<skill-name>.zip` files you
|
|
2173
|
+
want to install. One ZIP = one skill.
|
|
2174
|
+
4. Repeat per skill. Claude Desktop keeps each upload until you remove it.
|
|
2175
|
+
|
|
2176
|
+
The bundle directory is regenerated on every
|
|
2177
|
+
`npx @event4u/agent-config init --tools=claude-desktop` run (only
|
|
2178
|
+
changed skills are rewritten — content-hash idempotency).
|
|
2179
|
+
|
|
2180
|
+
To remove this marker, delete this file.
|
|
2181
|
+
"""
|
|
2182
|
+
|
|
2183
|
+
#: Subpath under ``~/.event4u/agent-config/`` where Claude Desktop ZIP
|
|
2184
|
+
#: bundles are written. Kept separate from the per-tool USER_SCOPE_PATHS
|
|
2185
|
+
#: anchor (which is the Claude Desktop config dir) because bundles are
|
|
2186
|
+
#: package-owned, not Claude-owned, content.
|
|
2187
|
+
_CLAUDE_DESKTOP_BUNDLES_SUBPATH = "claude-desktop/bundles"
|
|
2188
|
+
|
|
2189
|
+
|
|
1621
2190
|
def _bridge_marker(tool_id: str, scope: str) -> str:
|
|
1622
2191
|
"""Return the canonical bridge-marker path for ``(tool_id, scope)``.
|
|
1623
2192
|
|
|
@@ -1925,11 +2494,116 @@ def _load_installed_tools_module():
|
|
|
1925
2494
|
return installed_tools
|
|
1926
2495
|
|
|
1927
2496
|
|
|
2497
|
+
def _load_user_global_paths_module():
|
|
2498
|
+
"""Lazy-import ``scripts._lib.user_global_paths`` (Phase 3 migration shim)."""
|
|
2499
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
2500
|
+
if pkg_root not in sys.path:
|
|
2501
|
+
sys.path.insert(0, pkg_root)
|
|
2502
|
+
from scripts._lib import user_global_paths # noqa: WPS433 — lazy by design
|
|
2503
|
+
return user_global_paths
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
def _load_claude_desktop_bundler_module():
|
|
2507
|
+
"""Lazy-import ``scripts._lib.claude_desktop_bundler`` (Phase 4 ZIP bundler)."""
|
|
2508
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
2509
|
+
if pkg_root not in sys.path:
|
|
2510
|
+
sys.path.insert(0, pkg_root)
|
|
2511
|
+
from scripts._lib import claude_desktop_bundler # noqa: WPS433 — lazy by design
|
|
2512
|
+
return claude_desktop_bundler
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
def _sha256_of_file(path: Path) -> Optional[str]:
|
|
2516
|
+
"""Return the hex SHA-256 of ``path`` content, or ``None`` if unreadable.
|
|
2517
|
+
|
|
2518
|
+
Used by the v2 manifest (P1.4) to record content hashes for
|
|
2519
|
+
``deployed`` and ``marker`` files so drift can be detected later.
|
|
2520
|
+
Bridge files intentionally pass ``None`` (their content is a
|
|
2521
|
+
pointer, not committed bytes).
|
|
2522
|
+
"""
|
|
2523
|
+
try:
|
|
2524
|
+
data = path.read_bytes()
|
|
2525
|
+
except OSError:
|
|
2526
|
+
return None
|
|
2527
|
+
return hashlib.sha256(data).hexdigest()
|
|
2528
|
+
|
|
2529
|
+
|
|
2530
|
+
def _file_entry(path: Path, kind: str, *, hash_content: bool) -> dict[str, Any]:
|
|
2531
|
+
"""Build a v2 ``files[]`` entry from an absolute path.
|
|
2532
|
+
|
|
2533
|
+
``hash_content`` toggles SHA-256 computation; bridges pass ``False``
|
|
2534
|
+
(sha256 stays ``None``), deployed / marker files pass ``True``.
|
|
2535
|
+
The manifest is path-only at the wire level — we serialise the
|
|
2536
|
+
absolute path because user-scope files are not under ``project_root``.
|
|
2537
|
+
"""
|
|
2538
|
+
return {
|
|
2539
|
+
"path": str(path),
|
|
2540
|
+
"kind": kind,
|
|
2541
|
+
"sha256": _sha256_of_file(path) if hash_content else None,
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
def _files_by_tool_from_deploy(
|
|
2546
|
+
deploy_results: dict[str, tuple[int, int, str, list[Path]]],
|
|
2547
|
+
project_root: Path,
|
|
2548
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
2549
|
+
"""Translate ``_deploy_global_content`` output into v2 manifest entries.
|
|
2550
|
+
|
|
2551
|
+
Returns ``{tool_id: [files[]]}``. ``status=deployed`` paths get
|
|
2552
|
+
``kind=deployed``; ``status=marker`` paths get ``kind=marker``.
|
|
2553
|
+
``hint`` / ``unsupported`` tools produce no entries (nothing was
|
|
2554
|
+
written). Empty path lists are emitted as empty lists so the
|
|
2555
|
+
inventory replaces rather than preserves a stale prior set.
|
|
2556
|
+
"""
|
|
2557
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
2558
|
+
for tool_id, (_, _, status, paths) in deploy_results.items():
|
|
2559
|
+
if status == "deployed":
|
|
2560
|
+
out[tool_id] = [
|
|
2561
|
+
_file_entry(p, "deployed", hash_content=True) for p in paths
|
|
2562
|
+
]
|
|
2563
|
+
elif status == "marker":
|
|
2564
|
+
out[tool_id] = [
|
|
2565
|
+
_file_entry(p, "marker", hash_content=True) for p in paths
|
|
2566
|
+
]
|
|
2567
|
+
else:
|
|
2568
|
+
# No files written — record empty list so a re-install with
|
|
2569
|
+
# a smaller set actually shrinks the recorded inventory.
|
|
2570
|
+
out[tool_id] = []
|
|
2571
|
+
return out
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
def _files_by_tool_from_bridges(
|
|
2575
|
+
tools: set[str],
|
|
2576
|
+
project_root: Path,
|
|
2577
|
+
scope: str,
|
|
2578
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
2579
|
+
"""Build v2 ``files[]`` entries from project-scope bridge markers.
|
|
2580
|
+
|
|
2581
|
+
Each project-scope tool contributes a single ``kind=bridge`` entry
|
|
2582
|
+
pointing at its marker file. Bridges are pointers (not content we
|
|
2583
|
+
own bytes-for-bytes), so ``sha256`` stays ``None`` per the schema.
|
|
2584
|
+
"""
|
|
2585
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
2586
|
+
for tool_id in sorted(tools):
|
|
2587
|
+
marker = _bridge_marker(tool_id, scope)
|
|
2588
|
+
if not marker:
|
|
2589
|
+
continue
|
|
2590
|
+
marker_path = Path(marker)
|
|
2591
|
+
if not marker_path.is_absolute():
|
|
2592
|
+
marker_path = project_root / marker_path
|
|
2593
|
+
out[tool_id] = [
|
|
2594
|
+
_file_entry(marker_path, "bridge", hash_content=False),
|
|
2595
|
+
]
|
|
2596
|
+
return out
|
|
2597
|
+
|
|
2598
|
+
|
|
1928
2599
|
def _update_installed_tools_manifest(
|
|
1929
2600
|
project_root: Path,
|
|
1930
2601
|
tools: set[str],
|
|
1931
2602
|
scope: str,
|
|
1932
2603
|
force: bool,
|
|
2604
|
+
*,
|
|
2605
|
+
files_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
|
|
2606
|
+
merged_keys_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
|
|
1933
2607
|
) -> int:
|
|
1934
2608
|
"""Append / refresh project-scope manifest entries (ADR-008 Phase 3.2).
|
|
1935
2609
|
|
|
@@ -1939,6 +2613,14 @@ def _update_installed_tools_manifest(
|
|
|
1939
2613
|
(behaviour). Idempotent on (name, scope) match; refuses scope changes
|
|
1940
2614
|
without ``--force`` per ADR-008 § Lifecycle.
|
|
1941
2615
|
|
|
2616
|
+
``files_by_tool`` (P1.4) is the per-tool inventory of paths the
|
|
2617
|
+
install just wrote. When omitted the manifest preserves any prior
|
|
2618
|
+
``files[]`` on idempotent re-installs and emits none on first write.
|
|
2619
|
+
|
|
2620
|
+
``merged_keys_by_tool`` (P1.5) is the per-tool inventory of JSON
|
|
2621
|
+
pointers the install merged into shared files (e.g. ``.cursor/hooks.json``).
|
|
2622
|
+
Same idempotency contract as ``files_by_tool``.
|
|
2623
|
+
|
|
1942
2624
|
Returns 0 on success, 1 on refusal (scope mismatch without ``--force``).
|
|
1943
2625
|
"""
|
|
1944
2626
|
tools_mod = _load_installed_tools_module()
|
|
@@ -1954,6 +2636,10 @@ def _update_installed_tools_manifest(
|
|
|
1954
2636
|
if not marker:
|
|
1955
2637
|
# Substrate (vscode) or unknown — not tracked in the manifest.
|
|
1956
2638
|
continue
|
|
2639
|
+
files = files_by_tool.get(tool_id) if files_by_tool else None
|
|
2640
|
+
merged_keys = (
|
|
2641
|
+
merged_keys_by_tool.get(tool_id) if merged_keys_by_tool else None
|
|
2642
|
+
)
|
|
1957
2643
|
try:
|
|
1958
2644
|
entries = tools_mod.upsert_tool(
|
|
1959
2645
|
entries,
|
|
@@ -1961,6 +2647,8 @@ def _update_installed_tools_manifest(
|
|
|
1961
2647
|
scope=scope,
|
|
1962
2648
|
bridge_marker=marker,
|
|
1963
2649
|
force=force,
|
|
2650
|
+
files=files,
|
|
2651
|
+
merged_keys=merged_keys,
|
|
1964
2652
|
)
|
|
1965
2653
|
except tools_mod.ScopeMismatchError as exc:
|
|
1966
2654
|
if not QUIET:
|
|
@@ -1975,6 +2663,290 @@ def _update_installed_tools_manifest(
|
|
|
1975
2663
|
return 0
|
|
1976
2664
|
|
|
1977
2665
|
|
|
2666
|
+
# --- Global content deployment (ADR-007 user-scope file writes) ---
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
def _resolve_package_root_for_global() -> Path:
|
|
2670
|
+
"""Locate the agent-config package root for global content deployment.
|
|
2671
|
+
|
|
2672
|
+
Resolves relative to ``scripts/install.py`` (one level up). Verified by
|
|
2673
|
+
the presence of ``config/profiles/minimal.ini`` so a misplaced copy of
|
|
2674
|
+
install.py outside the package fails loudly instead of writing nothing.
|
|
2675
|
+
"""
|
|
2676
|
+
here = Path(__file__).resolve()
|
|
2677
|
+
candidate = here.parent.parent
|
|
2678
|
+
if not (candidate / "config" / "profiles" / "minimal.ini").exists():
|
|
2679
|
+
fail(
|
|
2680
|
+
f"Could not locate agent-config package root from {here}. "
|
|
2681
|
+
"Expected config/profiles/minimal.ini at the parent directory."
|
|
2682
|
+
)
|
|
2683
|
+
return candidate
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
#: Inline package identifier injected into deployed Markdown
|
|
2687
|
+
#: frontmatter (P5.1). Human-readable provenance only; the manifest
|
|
2688
|
+
#: remains the authoritative ownership source (see P5.3).
|
|
2689
|
+
PACKAGE_TAG_ID = "event4u/agent-config"
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def _inject_package_tag(
|
|
2693
|
+
target: Path, source: Path | None, package_root: Path | None,
|
|
2694
|
+
) -> None:
|
|
2695
|
+
"""Inject ``package:`` / ``source_path:`` keys into existing frontmatter.
|
|
2696
|
+
|
|
2697
|
+
No-ops for files that don't end in ``.md`` or that lack a leading
|
|
2698
|
+
``---`` frontmatter block (P5.1: don't synthesise frontmatter where
|
|
2699
|
+
none exists). Idempotent: running on an already-tagged file
|
|
2700
|
+
rewrites the same values without growing the block.
|
|
2701
|
+
|
|
2702
|
+
``source`` is the file we copied **from** (post-symlink-resolution
|
|
2703
|
+
when applicable); when ``package_root`` is supplied and contains
|
|
2704
|
+
``source``, the recorded value is the path relative to the package
|
|
2705
|
+
root, otherwise the absolute path. ``source=None`` skips the
|
|
2706
|
+
``source_path`` key but still maintains the ``package`` key.
|
|
2707
|
+
|
|
2708
|
+
The injected key is ``source_path:`` (not ``source:``) to avoid
|
|
2709
|
+
colliding with the established ``source: package`` origin-type
|
|
2710
|
+
convention used by 200+ rule files in this and downstream packages.
|
|
2711
|
+
"""
|
|
2712
|
+
if target.suffix != ".md":
|
|
2713
|
+
return
|
|
2714
|
+
try:
|
|
2715
|
+
text = target.read_text(encoding="utf-8")
|
|
2716
|
+
except OSError:
|
|
2717
|
+
return
|
|
2718
|
+
if not text.startswith("---\n") and not text.startswith("---\r\n"):
|
|
2719
|
+
return
|
|
2720
|
+
# Locate the closing fence — second ``---`` on its own line.
|
|
2721
|
+
lines = text.splitlines(keepends=True)
|
|
2722
|
+
close_idx: int | None = None
|
|
2723
|
+
for i in range(1, len(lines)):
|
|
2724
|
+
if lines[i].rstrip("\r\n") == "---":
|
|
2725
|
+
close_idx = i
|
|
2726
|
+
break
|
|
2727
|
+
if close_idx is None:
|
|
2728
|
+
return
|
|
2729
|
+
fm_lines = lines[1:close_idx]
|
|
2730
|
+
body_lines = lines[close_idx:]
|
|
2731
|
+
|
|
2732
|
+
source_value: str | None = None
|
|
2733
|
+
if source is not None:
|
|
2734
|
+
try:
|
|
2735
|
+
resolved_src = source.resolve()
|
|
2736
|
+
except OSError:
|
|
2737
|
+
resolved_src = source
|
|
2738
|
+
if package_root is not None:
|
|
2739
|
+
try:
|
|
2740
|
+
source_value = str(
|
|
2741
|
+
resolved_src.relative_to(package_root.resolve()),
|
|
2742
|
+
)
|
|
2743
|
+
except ValueError:
|
|
2744
|
+
source_value = str(resolved_src)
|
|
2745
|
+
else:
|
|
2746
|
+
source_value = str(resolved_src)
|
|
2747
|
+
|
|
2748
|
+
def _set_key(block: list[str], key: str, value: str) -> list[str]:
|
|
2749
|
+
prefix = f"{key}:"
|
|
2750
|
+
rendered = f"{key}: {value}\n"
|
|
2751
|
+
for idx, line in enumerate(block):
|
|
2752
|
+
if line.startswith(prefix):
|
|
2753
|
+
block[idx] = rendered
|
|
2754
|
+
return block
|
|
2755
|
+
block.append(rendered)
|
|
2756
|
+
return block
|
|
2757
|
+
|
|
2758
|
+
fm_lines = _set_key(fm_lines, "package", PACKAGE_TAG_ID)
|
|
2759
|
+
if source_value is not None:
|
|
2760
|
+
fm_lines = _set_key(fm_lines, "source_path", source_value)
|
|
2761
|
+
new_text = "".join(lines[:1] + fm_lines + body_lines)
|
|
2762
|
+
if new_text != text:
|
|
2763
|
+
target.write_text(new_text, encoding="utf-8")
|
|
2764
|
+
|
|
2765
|
+
|
|
2766
|
+
def _copy_dir_dereferencing_symlinks(
|
|
2767
|
+
src: Path, dest: Path, force: bool,
|
|
2768
|
+
*,
|
|
2769
|
+
package_root: Path | None = None,
|
|
2770
|
+
) -> tuple[int, int, list[Path]]:
|
|
2771
|
+
"""Recursively copy ``src`` into ``dest``, dereferencing every symlink.
|
|
2772
|
+
|
|
2773
|
+
Returns ``(files_written, files_skipped, written_paths)``. The third
|
|
2774
|
+
element is the absolute path list of every file the copy actually
|
|
2775
|
+
wrote (P1.4 — manifest needs to record the inventory). ``dest`` is
|
|
2776
|
+
created if missing. Existing files at ``dest`` are overwritten only
|
|
2777
|
+
when ``force=True``; otherwise skipped silently and counted as
|
|
2778
|
+
``skipped``. Symlinks in ``src`` are resolved so the user-scope copy
|
|
2779
|
+
survives npx cache eviction (the source tree under
|
|
2780
|
+
``~/.npm/_npx/<hash>/`` is transient).
|
|
2781
|
+
|
|
2782
|
+
When ``package_root`` is supplied, deployed ``.md`` files get an
|
|
2783
|
+
inline package tag injected into their frontmatter (P5.1).
|
|
2784
|
+
"""
|
|
2785
|
+
written = 0
|
|
2786
|
+
skipped = 0
|
|
2787
|
+
written_paths: list[Path] = []
|
|
2788
|
+
if not src.exists():
|
|
2789
|
+
return (0, 0, written_paths)
|
|
2790
|
+
if not src.is_dir():
|
|
2791
|
+
# Single-file source (e.g. .windsurfrules): copy as one file.
|
|
2792
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
2793
|
+
decision = _resolve_file_conflict(dest, force_hint=force)
|
|
2794
|
+
if decision == "skip":
|
|
2795
|
+
return (0, 1, written_paths)
|
|
2796
|
+
shutil.copyfile(src, dest, follow_symlinks=True)
|
|
2797
|
+
_inject_package_tag(dest, src, package_root)
|
|
2798
|
+
written_paths.append(dest)
|
|
2799
|
+
return (1, 0, written_paths)
|
|
2800
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
2801
|
+
for entry in src.rglob("*"):
|
|
2802
|
+
rel = entry.relative_to(src)
|
|
2803
|
+
target = dest / rel
|
|
2804
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
2805
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
2806
|
+
continue
|
|
2807
|
+
# Resolve symlinks to their real targets. ``follow_symlinks=True``
|
|
2808
|
+
# on copyfile produces a real file at the destination.
|
|
2809
|
+
resolved = entry.resolve()
|
|
2810
|
+
if resolved.is_dir():
|
|
2811
|
+
# Symlinked subdir — recurse into the resolved path.
|
|
2812
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
2813
|
+
sub_w, sub_s, sub_p = _copy_dir_dereferencing_symlinks(
|
|
2814
|
+
resolved, target, force, package_root=package_root,
|
|
2815
|
+
)
|
|
2816
|
+
written += sub_w
|
|
2817
|
+
skipped += sub_s
|
|
2818
|
+
written_paths.extend(sub_p)
|
|
2819
|
+
continue
|
|
2820
|
+
decision = _resolve_file_conflict(target, force_hint=force)
|
|
2821
|
+
if decision == "skip":
|
|
2822
|
+
skipped += 1
|
|
2823
|
+
continue
|
|
2824
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2825
|
+
shutil.copyfile(resolved, target, follow_symlinks=True)
|
|
2826
|
+
_inject_package_tag(target, resolved, package_root)
|
|
2827
|
+
written += 1
|
|
2828
|
+
written_paths.append(target)
|
|
2829
|
+
return (written, skipped, written_paths)
|
|
2830
|
+
|
|
2831
|
+
|
|
2832
|
+
def _claude_desktop_bundles_dir() -> Path:
|
|
2833
|
+
"""Return the canonical bundle output dir under the event4u namespace.
|
|
2834
|
+
|
|
2835
|
+
Located via :func:`user_global_paths.write_target` so the path
|
|
2836
|
+
honours the ``EVENT4U_HOME`` env override used by tests.
|
|
2837
|
+
"""
|
|
2838
|
+
paths_mod = _load_user_global_paths_module()
|
|
2839
|
+
return paths_mod.write_target(_CLAUDE_DESKTOP_BUNDLES_SUBPATH)
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
def _write_claude_desktop_marker(
|
|
2843
|
+
force: bool,
|
|
2844
|
+
lockfile_path: Path,
|
|
2845
|
+
*,
|
|
2846
|
+
bundles_dir: Path,
|
|
2847
|
+
bundle_count: int,
|
|
2848
|
+
) -> tuple[int, int, list[Path]]:
|
|
2849
|
+
"""Write the Claude Desktop user-scope marker file.
|
|
2850
|
+
|
|
2851
|
+
Returns ``(written, skipped, written_paths)`` for symmetry with the
|
|
2852
|
+
tree copier (P1.4). The marker points users at ``bundles_dir`` for
|
|
2853
|
+
the Customize → Skills import flow (Phase 4). Existing markers are
|
|
2854
|
+
overwritten unconditionally because the bundle count is part of the
|
|
2855
|
+
body and we want it to stay current.
|
|
2856
|
+
"""
|
|
2857
|
+
anchor = Path(USER_SCOPE_PATHS["claude-desktop"]).expanduser()
|
|
2858
|
+
target = anchor / "agent-config.md"
|
|
2859
|
+
anchor.mkdir(parents=True, exist_ok=True)
|
|
2860
|
+
body = _CLAUDE_DESKTOP_MARKER_TEMPLATE.format(
|
|
2861
|
+
lockfile=str(lockfile_path),
|
|
2862
|
+
anchor=str(anchor),
|
|
2863
|
+
bundles_dir=str(bundles_dir),
|
|
2864
|
+
bundle_count=bundle_count,
|
|
2865
|
+
)
|
|
2866
|
+
target.write_text(body, encoding="utf-8")
|
|
2867
|
+
return (1, 0, [target])
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
def _deploy_claude_desktop(
|
|
2871
|
+
force: bool, package_root: Path, lockfile_path: Path,
|
|
2872
|
+
) -> tuple[int, int, str, list[Path]]:
|
|
2873
|
+
"""Build skill ZIP bundles + write the marker for ``claude-desktop``.
|
|
2874
|
+
|
|
2875
|
+
Phase 4 of road-to-event4u-namespace-and-claude-desktop. Bundles
|
|
2876
|
+
land in ``~/.event4u/agent-config/claude-desktop/bundles/``; the
|
|
2877
|
+
marker file in the Claude Desktop config dir points at them with
|
|
2878
|
+
Customize → Skills import instructions.
|
|
2879
|
+
|
|
2880
|
+
Returns ``(bundle_count, 0, "deployed", [bundles_dir, marker])``.
|
|
2881
|
+
The ``deployed`` status replaces the v2.3 ``marker``-only status.
|
|
2882
|
+
"""
|
|
2883
|
+
bundler = _load_claude_desktop_bundler_module()
|
|
2884
|
+
bundles_dir = _claude_desktop_bundles_dir()
|
|
2885
|
+
bundler.build_skill_bundles(package_root, bundles_dir, force=force)
|
|
2886
|
+
# Count total existing ZIPs (idempotent runs may not rewrite any).
|
|
2887
|
+
bundle_count = sum(1 for _ in bundles_dir.glob("*.zip")) if bundles_dir.is_dir() else 0
|
|
2888
|
+
_, _, marker_paths = _write_claude_desktop_marker(
|
|
2889
|
+
force, lockfile_path, bundles_dir=bundles_dir, bundle_count=bundle_count,
|
|
2890
|
+
)
|
|
2891
|
+
return (bundle_count, 0, "deployed", [bundles_dir, *marker_paths])
|
|
2892
|
+
|
|
2893
|
+
|
|
2894
|
+
def _deploy_global_content(
|
|
2895
|
+
tools: set[str],
|
|
2896
|
+
force: bool,
|
|
2897
|
+
package_root: Path,
|
|
2898
|
+
lockfile_path: Path,
|
|
2899
|
+
) -> dict[str, tuple[int, int, str, list[Path]]]:
|
|
2900
|
+
"""Deploy per-tool content into user-scope anchors for ``tools``.
|
|
2901
|
+
|
|
2902
|
+
For each tool in ``tools`` that has a ``GLOBAL_DEPLOY_SOURCES`` entry,
|
|
2903
|
+
copies the listed package subtrees into ``USER_SCOPE_PATHS[tool_id]``
|
|
2904
|
+
(expanded). For ``claude-desktop`` builds per-skill ZIP bundles under
|
|
2905
|
+
``~/.event4u/agent-config/claude-desktop/bundles/`` and writes the
|
|
2906
|
+
marker file pointing at them (Phase 4). For tools without a deployment
|
|
2907
|
+
plan (e.g. ``copilot``), records a ``hint`` status so the caller can
|
|
2908
|
+
print an actionable next step.
|
|
2909
|
+
|
|
2910
|
+
Returns ``{tool_id: (written, skipped, status, written_paths)}``
|
|
2911
|
+
where ``status`` is one of ``deployed``, ``hint``, ``unsupported``
|
|
2912
|
+
and ``written_paths`` is the absolute path list of every file the
|
|
2913
|
+
deploy actually wrote (P1.4).
|
|
2914
|
+
"""
|
|
2915
|
+
results: dict[str, tuple[int, int, str, list[Path]]] = {}
|
|
2916
|
+
for tool_id in sorted(tools):
|
|
2917
|
+
if tool_id == "claude-desktop":
|
|
2918
|
+
results[tool_id] = _deploy_claude_desktop(force, package_root, lockfile_path)
|
|
2919
|
+
continue
|
|
2920
|
+
plan = GLOBAL_DEPLOY_SOURCES.get(tool_id)
|
|
2921
|
+
if plan is None:
|
|
2922
|
+
# No deployable content yet for this tool in global scope.
|
|
2923
|
+
# `copilot` has no user-scope convention. `aider` is a single
|
|
2924
|
+
# YAML file (not a directory). `zed` / `jetbrains` have no
|
|
2925
|
+
# markdown-skills convention. Each prints an actionable hint.
|
|
2926
|
+
status = "hint" if tool_id in {"copilot", "aider", "zed", "jetbrains"} else "unsupported"
|
|
2927
|
+
results[tool_id] = (0, 0, status, [])
|
|
2928
|
+
continue
|
|
2929
|
+
anchor_raw = USER_SCOPE_PATHS.get(tool_id)
|
|
2930
|
+
if not anchor_raw:
|
|
2931
|
+
results[tool_id] = (0, 0, "unsupported", [])
|
|
2932
|
+
continue
|
|
2933
|
+
anchor = Path(anchor_raw).expanduser()
|
|
2934
|
+
written_total = 0
|
|
2935
|
+
skipped_total = 0
|
|
2936
|
+
written_paths: list[Path] = []
|
|
2937
|
+
for src_rel, dest_sub in plan:
|
|
2938
|
+
src = package_root / src_rel
|
|
2939
|
+
dest = anchor / dest_sub if dest_sub else anchor
|
|
2940
|
+
w, s, paths = _copy_dir_dereferencing_symlinks(
|
|
2941
|
+
src, dest, force, package_root=package_root,
|
|
2942
|
+
)
|
|
2943
|
+
written_total += w
|
|
2944
|
+
skipped_total += s
|
|
2945
|
+
written_paths.extend(paths)
|
|
2946
|
+
results[tool_id] = (written_total, skipped_total, "deployed", written_paths)
|
|
2947
|
+
return results
|
|
2948
|
+
|
|
2949
|
+
|
|
1978
2950
|
def install_global(
|
|
1979
2951
|
tools: set[str],
|
|
1980
2952
|
force: bool,
|
|
@@ -1982,19 +2954,38 @@ def install_global(
|
|
|
1982
2954
|
) -> int:
|
|
1983
2955
|
"""User-scope install path (ADR-007 + Phase 1.6 lockfile lifecycle).
|
|
1984
2956
|
|
|
1985
|
-
Reads ``~/.
|
|
1986
|
-
|
|
2957
|
+
Reads ``~/.event4u/agent-config/installed.lock`` first (with a read
|
|
2958
|
+
fallback to the legacy ``~/.config/agent-config/installed.lock``). A
|
|
2959
|
+
recorded version that does not match the running package version refuses the
|
|
1987
2960
|
install with a remediation hint unless ``--force`` is passed. On
|
|
1988
2961
|
success the lockfile is rewritten atomically with the current
|
|
1989
2962
|
package version + the union of previously-recorded and now-installed
|
|
1990
|
-
tool IDs
|
|
1991
|
-
|
|
2963
|
+
tool IDs, then per-tool content (rules / skills / personas / etc.) is
|
|
2964
|
+
copied from the agent-config package into each tool's user-scope
|
|
2965
|
+
anchor (``GLOBAL_DEPLOY_SOURCES``). ``copilot`` is the lone headline
|
|
2966
|
+
exception — it has no user-scope convention, so it is reported with a
|
|
2967
|
+
hint pointing at ``agent-config export --tool=copilot``.
|
|
1992
2968
|
|
|
1993
2969
|
When ``project_root`` points at a project tree (detected by the
|
|
1994
2970
|
presence of ``.agent-settings.yml``), the project-scope manifest at
|
|
1995
2971
|
``agents/installed-tools.lock`` is also refreshed with ``scope=global``
|
|
1996
2972
|
entries per ADR-008 Phase 3.2.
|
|
2973
|
+
|
|
2974
|
+
Phase 3 namespace migration: before any lockfile read, the legacy
|
|
2975
|
+
``~/.config/agent-config/`` tree (pre-2.4 installs) is migrated into
|
|
2976
|
+
``~/.event4u/agent-config/`` so subsequent reads land on the canonical
|
|
2977
|
+
path. The migration is idempotent and leaves a ``MIGRATED.md``
|
|
2978
|
+
breadcrumb behind; the legacy tree is never auto-deleted.
|
|
1997
2979
|
"""
|
|
2980
|
+
paths_mod = _load_user_global_paths_module()
|
|
2981
|
+
migrated = paths_mod.migrate_legacy_namespace()
|
|
2982
|
+
if migrated and not QUIET:
|
|
2983
|
+
info(
|
|
2984
|
+
"🔁 Migrated user-global config to "
|
|
2985
|
+
f"{paths_mod.event4u_root()} (legacy "
|
|
2986
|
+
f"{paths_mod.legacy_xdg_root()} preserved as fallback)"
|
|
2987
|
+
)
|
|
2988
|
+
|
|
1998
2989
|
lock_mod = _load_installed_lock_module()
|
|
1999
2990
|
installed_version = lock_mod.current_package_version()
|
|
2000
2991
|
lock_path = lock_mod.lockfile_path()
|
|
@@ -2015,7 +3006,7 @@ def install_global(
|
|
|
2015
3006
|
if not QUIET:
|
|
2016
3007
|
print()
|
|
2017
3008
|
info("Agent Config — Global (user-scope) install [ADR-007]")
|
|
2018
|
-
info("
|
|
3009
|
+
info("Per-tool anchor paths:")
|
|
2019
3010
|
for tool_id in sorted(tools):
|
|
2020
3011
|
anchor = USER_SCOPE_PATHS.get(tool_id)
|
|
2021
3012
|
if anchor is None:
|
|
@@ -2033,6 +3024,32 @@ def install_global(
|
|
|
2033
3024
|
info(f" schema_version=1, agent_config_version={installed_version}")
|
|
2034
3025
|
info(f" tools={','.join(merged_tools)}")
|
|
2035
3026
|
|
|
3027
|
+
# Deploy per-tool content into user-scope anchors. Sources resolve from
|
|
3028
|
+
# the agent-config package root (located via `__file__`, not the
|
|
3029
|
+
# caller's CWD); destinations are `USER_SCOPE_PATHS[tool_id]` (expanded).
|
|
3030
|
+
package_root = _resolve_package_root_for_global()
|
|
3031
|
+
deploy_results = _deploy_global_content(tools, force, package_root, written)
|
|
3032
|
+
|
|
3033
|
+
if not QUIET:
|
|
3034
|
+
print()
|
|
3035
|
+
info("Deployed per-tool content:")
|
|
3036
|
+
for tool_id in sorted(deploy_results):
|
|
3037
|
+
w, s, status, _ = deploy_results[tool_id]
|
|
3038
|
+
anchor = USER_SCOPE_PATHS.get(tool_id, "")
|
|
3039
|
+
if status == "deployed" and tool_id == "claude-desktop":
|
|
3040
|
+
bundles_dir = _claude_desktop_bundles_dir()
|
|
3041
|
+
print(f" {tool_id:<15} → {bundles_dir} ({w} bundles)")
|
|
3042
|
+
elif status == "deployed":
|
|
3043
|
+
print(f" {tool_id:<15} → {anchor} ({w} files, {s} skipped)")
|
|
3044
|
+
elif status == "marker":
|
|
3045
|
+
print(f" {tool_id:<15} → {anchor}agent-config.md ({'written' if w else 'skipped'})")
|
|
3046
|
+
elif status == "hint":
|
|
3047
|
+
print(f" {tool_id:<15} → no user-scope convention; use `agent-config export --tool={tool_id}`")
|
|
3048
|
+
else:
|
|
3049
|
+
print(f" {tool_id:<15} → no global-scope content yet (project-scope install supported)")
|
|
3050
|
+
if not force and any(s > 0 for _, s, _, _ in deploy_results.values()):
|
|
3051
|
+
info(" Re-run with --force to overwrite existing files.")
|
|
3052
|
+
|
|
2036
3053
|
# Refresh the project-scope manifest when running inside a project tree
|
|
2037
3054
|
# (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
|
|
2038
3055
|
# manifest to write and the global lockfile alone is the source of truth.
|
|
@@ -2044,13 +3061,21 @@ def install_global(
|
|
|
2044
3061
|
and (project_root / SETTINGS_FILE).exists()
|
|
2045
3062
|
and not (project_root / ".agent-src.uncompressed").is_dir()
|
|
2046
3063
|
):
|
|
2047
|
-
|
|
3064
|
+
# Collect deployed/marker paths per tool so the manifest records
|
|
3065
|
+
# the v2 ``files[]`` inventory (P1.4).
|
|
3066
|
+
files_by_tool = _files_by_tool_from_deploy(
|
|
3067
|
+
deploy_results, project_root,
|
|
3068
|
+
)
|
|
3069
|
+
rc = _update_installed_tools_manifest(
|
|
3070
|
+
project_root, tools, "global", force,
|
|
3071
|
+
files_by_tool=files_by_tool,
|
|
3072
|
+
)
|
|
2048
3073
|
if rc != 0:
|
|
2049
3074
|
return rc
|
|
2050
3075
|
|
|
2051
3076
|
if not QUIET:
|
|
2052
3077
|
print()
|
|
2053
|
-
success("Global install
|
|
3078
|
+
success("Global install completed.")
|
|
2054
3079
|
print()
|
|
2055
3080
|
return 0
|
|
2056
3081
|
|
|
@@ -2167,6 +3192,18 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
2167
3192
|
"for --scope=global)."
|
|
2168
3193
|
),
|
|
2169
3194
|
)
|
|
3195
|
+
parser.add_argument(
|
|
3196
|
+
"--offline",
|
|
3197
|
+
action="store_true",
|
|
3198
|
+
help=(
|
|
3199
|
+
"skip every network call: suppress the post-install update "
|
|
3200
|
+
"banner and set AGENT_CONFIG_OFFLINE=1 for downstream "
|
|
3201
|
+
"subprocesses (versions / update / future fetchers). "
|
|
3202
|
+
"All bridge content is bundled in the package, so install "
|
|
3203
|
+
"itself never touches the network — this flag is the "
|
|
3204
|
+
"explicit guarantee for air-gapped / CI runs."
|
|
3205
|
+
),
|
|
3206
|
+
)
|
|
2170
3207
|
opts = parser.parse_args(argv)
|
|
2171
3208
|
opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
|
|
2172
3209
|
if opts.scope == "global" and opts.custom_path:
|
|
@@ -2184,7 +3221,10 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
2184
3221
|
_VALID_TOOLS = {
|
|
2185
3222
|
"claude-code", "claude-desktop", "cursor", "windsurf", "cline",
|
|
2186
3223
|
"gemini-cli", "copilot", "augment", "aider", "codex", "roocode",
|
|
2187
|
-
"continue", "kilocode", "zed", "jetbrains", "kiro",
|
|
3224
|
+
"continue", "kilocode", "zed", "jetbrains", "kiro",
|
|
3225
|
+
# Phase 2.4 expansion (nextlevelbuilder/ui-ux-pro-max-skill anchors).
|
|
3226
|
+
"qoder", "opencode", "trae", "antigravity", "codebuddy", "droid", "warp",
|
|
3227
|
+
"all",
|
|
2188
3228
|
}
|
|
2189
3229
|
|
|
2190
3230
|
|
|
@@ -2231,6 +3271,15 @@ def main(argv: list[str]) -> int:
|
|
|
2231
3271
|
opts = parse_options(argv)
|
|
2232
3272
|
QUIET = opts.quiet
|
|
2233
3273
|
|
|
3274
|
+
# --offline: propagate via env so child subprocesses (versions /
|
|
3275
|
+
# update / check_update_banner) honor the air-gap guarantee
|
|
3276
|
+
# without each one needing its own flag. AGENT_CONFIG_NO_UPDATE_CHECK
|
|
3277
|
+
# is the canonical kill-switch for the post-install banner; the
|
|
3278
|
+
# broader AGENT_CONFIG_OFFLINE signals intent to future fetchers.
|
|
3279
|
+
if opts.offline:
|
|
3280
|
+
os.environ["AGENT_CONFIG_OFFLINE"] = "1"
|
|
3281
|
+
os.environ["AGENT_CONFIG_NO_UPDATE_CHECK"] = "1"
|
|
3282
|
+
|
|
2234
3283
|
if opts.profile not in SUPPORTED_PROFILES:
|
|
2235
3284
|
fail(f"Unsupported profile: {opts.profile}. Supported: {', '.join(SUPPORTED_PROFILES)}")
|
|
2236
3285
|
|
|
@@ -2252,14 +3301,42 @@ def main(argv: list[str]) -> int:
|
|
|
2252
3301
|
tools_was_all = _tools_was_all(opts.tools)
|
|
2253
3302
|
parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
|
|
2254
3303
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
3304
|
+
# Conflict policy: load known paths/pointers from the manifest once
|
|
3305
|
+
# so every writer can ask "is this ours?" before clobbering (P3.1 /
|
|
3306
|
+
# P3.3). Built from the project-scope manifest because that's the
|
|
3307
|
+
# only on-disk source of truth across both install scopes.
|
|
3308
|
+
policy_root = detect_root if scope == "global" else (
|
|
3309
|
+
custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
3310
|
+
)
|
|
3311
|
+
_set_conflict_policy(_load_conflict_policy(policy_root, opts.force))
|
|
3312
|
+
|
|
3313
|
+
try:
|
|
3314
|
+
if scope == "global":
|
|
3315
|
+
# Pass detect_root so the manifest refresh runs when --global is
|
|
3316
|
+
# invoked from within a project tree (ADR-008 Phase 3.2).
|
|
3317
|
+
return install_global(parsed_tools, opts.force, project_root=detect_root)
|
|
3318
|
+
|
|
3319
|
+
project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
3320
|
+
is_first_run = not (project_root / SETTINGS_FILE).exists()
|
|
3321
|
+
return _main_project_install(opts, project_root, parsed_tools, is_first_run)
|
|
3322
|
+
except ConflictAbort as exc:
|
|
3323
|
+
warn(exc.message)
|
|
3324
|
+
return 1
|
|
3325
|
+
finally:
|
|
3326
|
+
_set_conflict_policy(None)
|
|
3327
|
+
|
|
2259
3328
|
|
|
2260
|
-
|
|
2261
|
-
|
|
3329
|
+
def _main_project_install(
|
|
3330
|
+
opts: argparse.Namespace,
|
|
3331
|
+
project_root: Path,
|
|
3332
|
+
parsed_tools: set[str],
|
|
3333
|
+
is_first_run: bool,
|
|
3334
|
+
) -> int:
|
|
3335
|
+
"""Project-scope install body extracted from :func:`main`.
|
|
2262
3336
|
|
|
3337
|
+
Kept as a private helper so ``main()`` can wrap the entire install
|
|
3338
|
+
in a ``try/except ConflictAbort`` without rewriting indentation.
|
|
3339
|
+
"""
|
|
2263
3340
|
if opts.package:
|
|
2264
3341
|
package_root = Path(opts.package).resolve()
|
|
2265
3342
|
if not (package_root / "config" / "profiles" / "minimal.ini").exists():
|
|
@@ -2282,21 +3359,24 @@ def main(argv: list[str]) -> int:
|
|
|
2282
3359
|
|
|
2283
3360
|
tools = parsed_tools
|
|
2284
3361
|
|
|
3362
|
+
# Per-tool merged_keys collected from JSON bridge merges (P1.5).
|
|
3363
|
+
merged_keys_by_tool: dict[str, list[dict[str, Any]]] = {}
|
|
3364
|
+
|
|
2285
3365
|
if not opts.skip_bridges:
|
|
2286
3366
|
# Substrate bridges (always written; other tools symlink/depend on them).
|
|
2287
3367
|
ensure_vscode_bridge(project_root, package_type, opts.force)
|
|
2288
|
-
ensure_augment_bridge(project_root, opts.force)
|
|
3368
|
+
merged_keys_by_tool["augment"] = ensure_augment_bridge(project_root, opts.force)
|
|
2289
3369
|
# Tool-specific bridges (gated by --tools selection).
|
|
2290
3370
|
if _is_tool_enabled(tools, "claude-code"):
|
|
2291
|
-
ensure_claude_bridge(project_root, opts.force)
|
|
3371
|
+
merged_keys_by_tool["claude-code"] = ensure_claude_bridge(project_root, opts.force)
|
|
2292
3372
|
if _is_tool_enabled(tools, "cursor"):
|
|
2293
|
-
ensure_cursor_bridge(project_root, opts.force)
|
|
3373
|
+
merged_keys_by_tool["cursor"] = ensure_cursor_bridge(project_root, opts.force)
|
|
2294
3374
|
if _is_tool_enabled(tools, "cline"):
|
|
2295
3375
|
ensure_cline_bridge(project_root, opts.force)
|
|
2296
3376
|
if _is_tool_enabled(tools, "windsurf"):
|
|
2297
|
-
ensure_windsurf_bridge(project_root, opts.force)
|
|
3377
|
+
merged_keys_by_tool["windsurf"] = ensure_windsurf_bridge(project_root, opts.force)
|
|
2298
3378
|
if _is_tool_enabled(tools, "gemini-cli"):
|
|
2299
|
-
ensure_gemini_bridge(project_root, opts.force)
|
|
3379
|
+
merged_keys_by_tool["gemini-cli"] = ensure_gemini_bridge(project_root, opts.force)
|
|
2300
3380
|
if _is_tool_enabled(tools, "copilot"):
|
|
2301
3381
|
ensure_copilot_bridge(project_root, opts.force)
|
|
2302
3382
|
if _is_tool_enabled(tools, "roocode"):
|
|
@@ -2318,20 +3398,31 @@ def main(argv: list[str]) -> int:
|
|
|
2318
3398
|
if _is_tool_enabled(tools, "kiro"):
|
|
2319
3399
|
ensure_kiro_bridge(project_root, opts.force)
|
|
2320
3400
|
|
|
3401
|
+
# User-scope hook bridges contribute additional merged_keys to the
|
|
3402
|
+
# same tool entry (P1.5) — the manifest tracks every JSON pointer
|
|
3403
|
+
# the install wrote, regardless of which file owns it.
|
|
2321
3404
|
if opts.augment_user_hooks:
|
|
2322
|
-
|
|
3405
|
+
merged_keys_by_tool.setdefault("augment", []).extend(
|
|
3406
|
+
ensure_augment_user_hooks(package_root, opts.force),
|
|
3407
|
+
)
|
|
2323
3408
|
|
|
2324
3409
|
if opts.cursor_user_hooks and _is_tool_enabled(tools, "cursor"):
|
|
2325
|
-
|
|
3410
|
+
merged_keys_by_tool.setdefault("cursor", []).extend(
|
|
3411
|
+
ensure_cursor_user_hooks(package_root, opts.force),
|
|
3412
|
+
)
|
|
2326
3413
|
|
|
2327
3414
|
if opts.cline_user_hooks and _is_tool_enabled(tools, "cline"):
|
|
2328
3415
|
ensure_cline_user_hooks(package_root, opts.force)
|
|
2329
3416
|
|
|
2330
3417
|
if opts.windsurf_user_hooks and _is_tool_enabled(tools, "windsurf"):
|
|
2331
|
-
|
|
3418
|
+
merged_keys_by_tool.setdefault("windsurf", []).extend(
|
|
3419
|
+
ensure_windsurf_user_hooks(package_root, opts.force),
|
|
3420
|
+
)
|
|
2332
3421
|
|
|
2333
3422
|
if opts.gemini_user_hooks and _is_tool_enabled(tools, "gemini-cli"):
|
|
2334
|
-
|
|
3423
|
+
merged_keys_by_tool.setdefault("gemini-cli", []).extend(
|
|
3424
|
+
ensure_gemini_user_hooks(package_root, opts.force),
|
|
3425
|
+
)
|
|
2335
3426
|
|
|
2336
3427
|
if not opts.skip_bridges and not opts.no_smoke:
|
|
2337
3428
|
if not QUIET:
|
|
@@ -2344,8 +3435,13 @@ def main(argv: list[str]) -> int:
|
|
|
2344
3435
|
# markers actually exist. Skipped when `--skip-bridges` was used (the
|
|
2345
3436
|
# caller is exercising the install plan, not committing to it).
|
|
2346
3437
|
if not opts.skip_bridges:
|
|
3438
|
+
files_by_tool = _files_by_tool_from_bridges(
|
|
3439
|
+
parsed_tools, project_root, "project",
|
|
3440
|
+
)
|
|
2347
3441
|
rc = _update_installed_tools_manifest(
|
|
2348
3442
|
project_root, parsed_tools, "project", opts.force,
|
|
3443
|
+
files_by_tool=files_by_tool,
|
|
3444
|
+
merged_keys_by_tool=merged_keys_by_tool,
|
|
2349
3445
|
)
|
|
2350
3446
|
if rc != 0:
|
|
2351
3447
|
return rc
|