@event4u/agent-config 2.2.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +49 -0
- package/README.md +1 -8
- package/docs/architecture.md +1 -1
- package/docs/contracts/installed-tools-lockfile.md +138 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/installation.md +14 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +26 -3
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_tools.py +188 -44
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/agent-config +69 -0
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1053 -51
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,103 @@ 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
|
+
|
|
2164
|
+
Claude Desktop has no native rules / skills filesystem convention; this
|
|
2165
|
+
file is informational. Rules and skills for AI coding tools are deployed
|
|
2166
|
+
to their respective user-scope directories (`~/.claude/`, `~/.augment/`,
|
|
2167
|
+
`~/.cursor/`, `~/.codeium/windsurf/`, `~/Documents/Cline/Rules/`).
|
|
2168
|
+
|
|
2169
|
+
To remove this marker, delete this file.
|
|
2170
|
+
"""
|
|
2171
|
+
|
|
2172
|
+
|
|
1621
2173
|
def _bridge_marker(tool_id: str, scope: str) -> str:
|
|
1622
2174
|
"""Return the canonical bridge-marker path for ``(tool_id, scope)``.
|
|
1623
2175
|
|
|
@@ -1925,11 +2477,98 @@ def _load_installed_tools_module():
|
|
|
1925
2477
|
return installed_tools
|
|
1926
2478
|
|
|
1927
2479
|
|
|
2480
|
+
def _sha256_of_file(path: Path) -> Optional[str]:
|
|
2481
|
+
"""Return the hex SHA-256 of ``path`` content, or ``None`` if unreadable.
|
|
2482
|
+
|
|
2483
|
+
Used by the v2 manifest (P1.4) to record content hashes for
|
|
2484
|
+
``deployed`` and ``marker`` files so drift can be detected later.
|
|
2485
|
+
Bridge files intentionally pass ``None`` (their content is a
|
|
2486
|
+
pointer, not committed bytes).
|
|
2487
|
+
"""
|
|
2488
|
+
try:
|
|
2489
|
+
data = path.read_bytes()
|
|
2490
|
+
except OSError:
|
|
2491
|
+
return None
|
|
2492
|
+
return hashlib.sha256(data).hexdigest()
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
def _file_entry(path: Path, kind: str, *, hash_content: bool) -> dict[str, Any]:
|
|
2496
|
+
"""Build a v2 ``files[]`` entry from an absolute path.
|
|
2497
|
+
|
|
2498
|
+
``hash_content`` toggles SHA-256 computation; bridges pass ``False``
|
|
2499
|
+
(sha256 stays ``None``), deployed / marker files pass ``True``.
|
|
2500
|
+
The manifest is path-only at the wire level — we serialise the
|
|
2501
|
+
absolute path because user-scope files are not under ``project_root``.
|
|
2502
|
+
"""
|
|
2503
|
+
return {
|
|
2504
|
+
"path": str(path),
|
|
2505
|
+
"kind": kind,
|
|
2506
|
+
"sha256": _sha256_of_file(path) if hash_content else None,
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
|
|
2510
|
+
def _files_by_tool_from_deploy(
|
|
2511
|
+
deploy_results: dict[str, tuple[int, int, str, list[Path]]],
|
|
2512
|
+
project_root: Path,
|
|
2513
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
2514
|
+
"""Translate ``_deploy_global_content`` output into v2 manifest entries.
|
|
2515
|
+
|
|
2516
|
+
Returns ``{tool_id: [files[]]}``. ``status=deployed`` paths get
|
|
2517
|
+
``kind=deployed``; ``status=marker`` paths get ``kind=marker``.
|
|
2518
|
+
``hint`` / ``unsupported`` tools produce no entries (nothing was
|
|
2519
|
+
written). Empty path lists are emitted as empty lists so the
|
|
2520
|
+
inventory replaces rather than preserves a stale prior set.
|
|
2521
|
+
"""
|
|
2522
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
2523
|
+
for tool_id, (_, _, status, paths) in deploy_results.items():
|
|
2524
|
+
if status == "deployed":
|
|
2525
|
+
out[tool_id] = [
|
|
2526
|
+
_file_entry(p, "deployed", hash_content=True) for p in paths
|
|
2527
|
+
]
|
|
2528
|
+
elif status == "marker":
|
|
2529
|
+
out[tool_id] = [
|
|
2530
|
+
_file_entry(p, "marker", hash_content=True) for p in paths
|
|
2531
|
+
]
|
|
2532
|
+
else:
|
|
2533
|
+
# No files written — record empty list so a re-install with
|
|
2534
|
+
# a smaller set actually shrinks the recorded inventory.
|
|
2535
|
+
out[tool_id] = []
|
|
2536
|
+
return out
|
|
2537
|
+
|
|
2538
|
+
|
|
2539
|
+
def _files_by_tool_from_bridges(
|
|
2540
|
+
tools: set[str],
|
|
2541
|
+
project_root: Path,
|
|
2542
|
+
scope: str,
|
|
2543
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
2544
|
+
"""Build v2 ``files[]`` entries from project-scope bridge markers.
|
|
2545
|
+
|
|
2546
|
+
Each project-scope tool contributes a single ``kind=bridge`` entry
|
|
2547
|
+
pointing at its marker file. Bridges are pointers (not content we
|
|
2548
|
+
own bytes-for-bytes), so ``sha256`` stays ``None`` per the schema.
|
|
2549
|
+
"""
|
|
2550
|
+
out: dict[str, list[dict[str, Any]]] = {}
|
|
2551
|
+
for tool_id in sorted(tools):
|
|
2552
|
+
marker = _bridge_marker(tool_id, scope)
|
|
2553
|
+
if not marker:
|
|
2554
|
+
continue
|
|
2555
|
+
marker_path = Path(marker)
|
|
2556
|
+
if not marker_path.is_absolute():
|
|
2557
|
+
marker_path = project_root / marker_path
|
|
2558
|
+
out[tool_id] = [
|
|
2559
|
+
_file_entry(marker_path, "bridge", hash_content=False),
|
|
2560
|
+
]
|
|
2561
|
+
return out
|
|
2562
|
+
|
|
2563
|
+
|
|
1928
2564
|
def _update_installed_tools_manifest(
|
|
1929
2565
|
project_root: Path,
|
|
1930
2566
|
tools: set[str],
|
|
1931
2567
|
scope: str,
|
|
1932
2568
|
force: bool,
|
|
2569
|
+
*,
|
|
2570
|
+
files_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
|
|
2571
|
+
merged_keys_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
|
|
1933
2572
|
) -> int:
|
|
1934
2573
|
"""Append / refresh project-scope manifest entries (ADR-008 Phase 3.2).
|
|
1935
2574
|
|
|
@@ -1939,6 +2578,14 @@ def _update_installed_tools_manifest(
|
|
|
1939
2578
|
(behaviour). Idempotent on (name, scope) match; refuses scope changes
|
|
1940
2579
|
without ``--force`` per ADR-008 § Lifecycle.
|
|
1941
2580
|
|
|
2581
|
+
``files_by_tool`` (P1.4) is the per-tool inventory of paths the
|
|
2582
|
+
install just wrote. When omitted the manifest preserves any prior
|
|
2583
|
+
``files[]`` on idempotent re-installs and emits none on first write.
|
|
2584
|
+
|
|
2585
|
+
``merged_keys_by_tool`` (P1.5) is the per-tool inventory of JSON
|
|
2586
|
+
pointers the install merged into shared files (e.g. ``.cursor/hooks.json``).
|
|
2587
|
+
Same idempotency contract as ``files_by_tool``.
|
|
2588
|
+
|
|
1942
2589
|
Returns 0 on success, 1 on refusal (scope mismatch without ``--force``).
|
|
1943
2590
|
"""
|
|
1944
2591
|
tools_mod = _load_installed_tools_module()
|
|
@@ -1954,6 +2601,10 @@ def _update_installed_tools_manifest(
|
|
|
1954
2601
|
if not marker:
|
|
1955
2602
|
# Substrate (vscode) or unknown — not tracked in the manifest.
|
|
1956
2603
|
continue
|
|
2604
|
+
files = files_by_tool.get(tool_id) if files_by_tool else None
|
|
2605
|
+
merged_keys = (
|
|
2606
|
+
merged_keys_by_tool.get(tool_id) if merged_keys_by_tool else None
|
|
2607
|
+
)
|
|
1957
2608
|
try:
|
|
1958
2609
|
entries = tools_mod.upsert_tool(
|
|
1959
2610
|
entries,
|
|
@@ -1961,6 +2612,8 @@ def _update_installed_tools_manifest(
|
|
|
1961
2612
|
scope=scope,
|
|
1962
2613
|
bridge_marker=marker,
|
|
1963
2614
|
force=force,
|
|
2615
|
+
files=files,
|
|
2616
|
+
merged_keys=merged_keys,
|
|
1964
2617
|
)
|
|
1965
2618
|
except tools_mod.ScopeMismatchError as exc:
|
|
1966
2619
|
if not QUIET:
|
|
@@ -1975,6 +2628,250 @@ def _update_installed_tools_manifest(
|
|
|
1975
2628
|
return 0
|
|
1976
2629
|
|
|
1977
2630
|
|
|
2631
|
+
# --- Global content deployment (ADR-007 user-scope file writes) ---
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def _resolve_package_root_for_global() -> Path:
|
|
2635
|
+
"""Locate the agent-config package root for global content deployment.
|
|
2636
|
+
|
|
2637
|
+
Resolves relative to ``scripts/install.py`` (one level up). Verified by
|
|
2638
|
+
the presence of ``config/profiles/minimal.ini`` so a misplaced copy of
|
|
2639
|
+
install.py outside the package fails loudly instead of writing nothing.
|
|
2640
|
+
"""
|
|
2641
|
+
here = Path(__file__).resolve()
|
|
2642
|
+
candidate = here.parent.parent
|
|
2643
|
+
if not (candidate / "config" / "profiles" / "minimal.ini").exists():
|
|
2644
|
+
fail(
|
|
2645
|
+
f"Could not locate agent-config package root from {here}. "
|
|
2646
|
+
"Expected config/profiles/minimal.ini at the parent directory."
|
|
2647
|
+
)
|
|
2648
|
+
return candidate
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
#: Inline package identifier injected into deployed Markdown
|
|
2652
|
+
#: frontmatter (P5.1). Human-readable provenance only; the manifest
|
|
2653
|
+
#: remains the authoritative ownership source (see P5.3).
|
|
2654
|
+
PACKAGE_TAG_ID = "event4u/agent-config"
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
def _inject_package_tag(
|
|
2658
|
+
target: Path, source: Path | None, package_root: Path | None,
|
|
2659
|
+
) -> None:
|
|
2660
|
+
"""Inject ``package:`` / ``source_path:`` keys into existing frontmatter.
|
|
2661
|
+
|
|
2662
|
+
No-ops for files that don't end in ``.md`` or that lack a leading
|
|
2663
|
+
``---`` frontmatter block (P5.1: don't synthesise frontmatter where
|
|
2664
|
+
none exists). Idempotent: running on an already-tagged file
|
|
2665
|
+
rewrites the same values without growing the block.
|
|
2666
|
+
|
|
2667
|
+
``source`` is the file we copied **from** (post-symlink-resolution
|
|
2668
|
+
when applicable); when ``package_root`` is supplied and contains
|
|
2669
|
+
``source``, the recorded value is the path relative to the package
|
|
2670
|
+
root, otherwise the absolute path. ``source=None`` skips the
|
|
2671
|
+
``source_path`` key but still maintains the ``package`` key.
|
|
2672
|
+
|
|
2673
|
+
The injected key is ``source_path:`` (not ``source:``) to avoid
|
|
2674
|
+
colliding with the established ``source: package`` origin-type
|
|
2675
|
+
convention used by 200+ rule files in this and downstream packages.
|
|
2676
|
+
"""
|
|
2677
|
+
if target.suffix != ".md":
|
|
2678
|
+
return
|
|
2679
|
+
try:
|
|
2680
|
+
text = target.read_text(encoding="utf-8")
|
|
2681
|
+
except OSError:
|
|
2682
|
+
return
|
|
2683
|
+
if not text.startswith("---\n") and not text.startswith("---\r\n"):
|
|
2684
|
+
return
|
|
2685
|
+
# Locate the closing fence — second ``---`` on its own line.
|
|
2686
|
+
lines = text.splitlines(keepends=True)
|
|
2687
|
+
close_idx: int | None = None
|
|
2688
|
+
for i in range(1, len(lines)):
|
|
2689
|
+
if lines[i].rstrip("\r\n") == "---":
|
|
2690
|
+
close_idx = i
|
|
2691
|
+
break
|
|
2692
|
+
if close_idx is None:
|
|
2693
|
+
return
|
|
2694
|
+
fm_lines = lines[1:close_idx]
|
|
2695
|
+
body_lines = lines[close_idx:]
|
|
2696
|
+
|
|
2697
|
+
source_value: str | None = None
|
|
2698
|
+
if source is not None:
|
|
2699
|
+
try:
|
|
2700
|
+
resolved_src = source.resolve()
|
|
2701
|
+
except OSError:
|
|
2702
|
+
resolved_src = source
|
|
2703
|
+
if package_root is not None:
|
|
2704
|
+
try:
|
|
2705
|
+
source_value = str(
|
|
2706
|
+
resolved_src.relative_to(package_root.resolve()),
|
|
2707
|
+
)
|
|
2708
|
+
except ValueError:
|
|
2709
|
+
source_value = str(resolved_src)
|
|
2710
|
+
else:
|
|
2711
|
+
source_value = str(resolved_src)
|
|
2712
|
+
|
|
2713
|
+
def _set_key(block: list[str], key: str, value: str) -> list[str]:
|
|
2714
|
+
prefix = f"{key}:"
|
|
2715
|
+
rendered = f"{key}: {value}\n"
|
|
2716
|
+
for idx, line in enumerate(block):
|
|
2717
|
+
if line.startswith(prefix):
|
|
2718
|
+
block[idx] = rendered
|
|
2719
|
+
return block
|
|
2720
|
+
block.append(rendered)
|
|
2721
|
+
return block
|
|
2722
|
+
|
|
2723
|
+
fm_lines = _set_key(fm_lines, "package", PACKAGE_TAG_ID)
|
|
2724
|
+
if source_value is not None:
|
|
2725
|
+
fm_lines = _set_key(fm_lines, "source_path", source_value)
|
|
2726
|
+
new_text = "".join(lines[:1] + fm_lines + body_lines)
|
|
2727
|
+
if new_text != text:
|
|
2728
|
+
target.write_text(new_text, encoding="utf-8")
|
|
2729
|
+
|
|
2730
|
+
|
|
2731
|
+
def _copy_dir_dereferencing_symlinks(
|
|
2732
|
+
src: Path, dest: Path, force: bool,
|
|
2733
|
+
*,
|
|
2734
|
+
package_root: Path | None = None,
|
|
2735
|
+
) -> tuple[int, int, list[Path]]:
|
|
2736
|
+
"""Recursively copy ``src`` into ``dest``, dereferencing every symlink.
|
|
2737
|
+
|
|
2738
|
+
Returns ``(files_written, files_skipped, written_paths)``. The third
|
|
2739
|
+
element is the absolute path list of every file the copy actually
|
|
2740
|
+
wrote (P1.4 — manifest needs to record the inventory). ``dest`` is
|
|
2741
|
+
created if missing. Existing files at ``dest`` are overwritten only
|
|
2742
|
+
when ``force=True``; otherwise skipped silently and counted as
|
|
2743
|
+
``skipped``. Symlinks in ``src`` are resolved so the user-scope copy
|
|
2744
|
+
survives npx cache eviction (the source tree under
|
|
2745
|
+
``~/.npm/_npx/<hash>/`` is transient).
|
|
2746
|
+
|
|
2747
|
+
When ``package_root`` is supplied, deployed ``.md`` files get an
|
|
2748
|
+
inline package tag injected into their frontmatter (P5.1).
|
|
2749
|
+
"""
|
|
2750
|
+
written = 0
|
|
2751
|
+
skipped = 0
|
|
2752
|
+
written_paths: list[Path] = []
|
|
2753
|
+
if not src.exists():
|
|
2754
|
+
return (0, 0, written_paths)
|
|
2755
|
+
if not src.is_dir():
|
|
2756
|
+
# Single-file source (e.g. .windsurfrules): copy as one file.
|
|
2757
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
2758
|
+
decision = _resolve_file_conflict(dest, force_hint=force)
|
|
2759
|
+
if decision == "skip":
|
|
2760
|
+
return (0, 1, written_paths)
|
|
2761
|
+
shutil.copyfile(src, dest, follow_symlinks=True)
|
|
2762
|
+
_inject_package_tag(dest, src, package_root)
|
|
2763
|
+
written_paths.append(dest)
|
|
2764
|
+
return (1, 0, written_paths)
|
|
2765
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
2766
|
+
for entry in src.rglob("*"):
|
|
2767
|
+
rel = entry.relative_to(src)
|
|
2768
|
+
target = dest / rel
|
|
2769
|
+
if entry.is_dir() and not entry.is_symlink():
|
|
2770
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
2771
|
+
continue
|
|
2772
|
+
# Resolve symlinks to their real targets. ``follow_symlinks=True``
|
|
2773
|
+
# on copyfile produces a real file at the destination.
|
|
2774
|
+
resolved = entry.resolve()
|
|
2775
|
+
if resolved.is_dir():
|
|
2776
|
+
# Symlinked subdir — recurse into the resolved path.
|
|
2777
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
2778
|
+
sub_w, sub_s, sub_p = _copy_dir_dereferencing_symlinks(
|
|
2779
|
+
resolved, target, force, package_root=package_root,
|
|
2780
|
+
)
|
|
2781
|
+
written += sub_w
|
|
2782
|
+
skipped += sub_s
|
|
2783
|
+
written_paths.extend(sub_p)
|
|
2784
|
+
continue
|
|
2785
|
+
decision = _resolve_file_conflict(target, force_hint=force)
|
|
2786
|
+
if decision == "skip":
|
|
2787
|
+
skipped += 1
|
|
2788
|
+
continue
|
|
2789
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
2790
|
+
shutil.copyfile(resolved, target, follow_symlinks=True)
|
|
2791
|
+
_inject_package_tag(target, resolved, package_root)
|
|
2792
|
+
written += 1
|
|
2793
|
+
written_paths.append(target)
|
|
2794
|
+
return (written, skipped, written_paths)
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def _write_claude_desktop_marker(
|
|
2798
|
+
force: bool, lockfile_path: Path,
|
|
2799
|
+
) -> tuple[int, int, list[Path]]:
|
|
2800
|
+
"""Write the Claude Desktop user-scope marker file.
|
|
2801
|
+
|
|
2802
|
+
Returns ``(written, skipped, written_paths)`` for symmetry with the
|
|
2803
|
+
tree copier (P1.4). The marker is a single Markdown file; existing
|
|
2804
|
+
files are preserved unless ``force=True``.
|
|
2805
|
+
"""
|
|
2806
|
+
anchor = Path(USER_SCOPE_PATHS["claude-desktop"]).expanduser()
|
|
2807
|
+
target = anchor / "agent-config.md"
|
|
2808
|
+
decision = _resolve_file_conflict(target, force_hint=force)
|
|
2809
|
+
if decision == "skip":
|
|
2810
|
+
return (0, 1, [])
|
|
2811
|
+
anchor.mkdir(parents=True, exist_ok=True)
|
|
2812
|
+
body = _CLAUDE_DESKTOP_MARKER_TEMPLATE.format(
|
|
2813
|
+
lockfile=str(lockfile_path),
|
|
2814
|
+
anchor=str(anchor),
|
|
2815
|
+
)
|
|
2816
|
+
target.write_text(body, encoding="utf-8")
|
|
2817
|
+
return (1, 0, [target])
|
|
2818
|
+
|
|
2819
|
+
|
|
2820
|
+
def _deploy_global_content(
|
|
2821
|
+
tools: set[str],
|
|
2822
|
+
force: bool,
|
|
2823
|
+
package_root: Path,
|
|
2824
|
+
lockfile_path: Path,
|
|
2825
|
+
) -> dict[str, tuple[int, int, str, list[Path]]]:
|
|
2826
|
+
"""Deploy per-tool content into user-scope anchors for ``tools``.
|
|
2827
|
+
|
|
2828
|
+
For each tool in ``tools`` that has a ``GLOBAL_DEPLOY_SOURCES`` entry,
|
|
2829
|
+
copies the listed package subtrees into ``USER_SCOPE_PATHS[tool_id]``
|
|
2830
|
+
(expanded). For ``claude-desktop`` writes the marker file. For tools
|
|
2831
|
+
without a deployment plan (e.g. ``copilot``), records a ``hint`` status
|
|
2832
|
+
so the caller can print an actionable next step.
|
|
2833
|
+
|
|
2834
|
+
Returns ``{tool_id: (written, skipped, status, written_paths)}``
|
|
2835
|
+
where ``status`` is one of ``deployed``, ``marker``, ``hint``,
|
|
2836
|
+
``unsupported`` and ``written_paths`` is the absolute path list of
|
|
2837
|
+
every file the deploy actually wrote (P1.4).
|
|
2838
|
+
"""
|
|
2839
|
+
results: dict[str, tuple[int, int, str, list[Path]]] = {}
|
|
2840
|
+
for tool_id in sorted(tools):
|
|
2841
|
+
if tool_id == "claude-desktop":
|
|
2842
|
+
w, s, paths = _write_claude_desktop_marker(force, lockfile_path)
|
|
2843
|
+
results[tool_id] = (w, s, "marker", paths)
|
|
2844
|
+
continue
|
|
2845
|
+
plan = GLOBAL_DEPLOY_SOURCES.get(tool_id)
|
|
2846
|
+
if plan is None:
|
|
2847
|
+
# No deployable content yet for this tool in global scope.
|
|
2848
|
+
# `copilot` has no user-scope convention. `aider` is a single
|
|
2849
|
+
# YAML file (not a directory). `zed` / `jetbrains` have no
|
|
2850
|
+
# markdown-skills convention. Each prints an actionable hint.
|
|
2851
|
+
status = "hint" if tool_id in {"copilot", "aider", "zed", "jetbrains"} else "unsupported"
|
|
2852
|
+
results[tool_id] = (0, 0, status, [])
|
|
2853
|
+
continue
|
|
2854
|
+
anchor_raw = USER_SCOPE_PATHS.get(tool_id)
|
|
2855
|
+
if not anchor_raw:
|
|
2856
|
+
results[tool_id] = (0, 0, "unsupported", [])
|
|
2857
|
+
continue
|
|
2858
|
+
anchor = Path(anchor_raw).expanduser()
|
|
2859
|
+
written_total = 0
|
|
2860
|
+
skipped_total = 0
|
|
2861
|
+
written_paths: list[Path] = []
|
|
2862
|
+
for src_rel, dest_sub in plan:
|
|
2863
|
+
src = package_root / src_rel
|
|
2864
|
+
dest = anchor / dest_sub if dest_sub else anchor
|
|
2865
|
+
w, s, paths = _copy_dir_dereferencing_symlinks(
|
|
2866
|
+
src, dest, force, package_root=package_root,
|
|
2867
|
+
)
|
|
2868
|
+
written_total += w
|
|
2869
|
+
skipped_total += s
|
|
2870
|
+
written_paths.extend(paths)
|
|
2871
|
+
results[tool_id] = (written_total, skipped_total, "deployed", written_paths)
|
|
2872
|
+
return results
|
|
2873
|
+
|
|
2874
|
+
|
|
1978
2875
|
def install_global(
|
|
1979
2876
|
tools: set[str],
|
|
1980
2877
|
force: bool,
|
|
@@ -1987,8 +2884,11 @@ def install_global(
|
|
|
1987
2884
|
install with a remediation hint unless ``--force`` is passed. On
|
|
1988
2885
|
success the lockfile is rewritten atomically with the current
|
|
1989
2886
|
package version + the union of previously-recorded and now-installed
|
|
1990
|
-
tool IDs
|
|
1991
|
-
|
|
2887
|
+
tool IDs, then per-tool content (rules / skills / personas / etc.) is
|
|
2888
|
+
copied from the agent-config package into each tool's user-scope
|
|
2889
|
+
anchor (``GLOBAL_DEPLOY_SOURCES``). ``copilot`` is the lone headline
|
|
2890
|
+
exception — it has no user-scope convention, so it is reported with a
|
|
2891
|
+
hint pointing at ``agent-config export --tool=copilot``.
|
|
1992
2892
|
|
|
1993
2893
|
When ``project_root`` points at a project tree (detected by the
|
|
1994
2894
|
presence of ``.agent-settings.yml``), the project-scope manifest at
|
|
@@ -2015,7 +2915,7 @@ def install_global(
|
|
|
2015
2915
|
if not QUIET:
|
|
2016
2916
|
print()
|
|
2017
2917
|
info("Agent Config — Global (user-scope) install [ADR-007]")
|
|
2018
|
-
info("
|
|
2918
|
+
info("Per-tool anchor paths:")
|
|
2019
2919
|
for tool_id in sorted(tools):
|
|
2020
2920
|
anchor = USER_SCOPE_PATHS.get(tool_id)
|
|
2021
2921
|
if anchor is None:
|
|
@@ -2033,6 +2933,29 @@ def install_global(
|
|
|
2033
2933
|
info(f" schema_version=1, agent_config_version={installed_version}")
|
|
2034
2934
|
info(f" tools={','.join(merged_tools)}")
|
|
2035
2935
|
|
|
2936
|
+
# Deploy per-tool content into user-scope anchors. Sources resolve from
|
|
2937
|
+
# the agent-config package root (located via `__file__`, not the
|
|
2938
|
+
# caller's CWD); destinations are `USER_SCOPE_PATHS[tool_id]` (expanded).
|
|
2939
|
+
package_root = _resolve_package_root_for_global()
|
|
2940
|
+
deploy_results = _deploy_global_content(tools, force, package_root, written)
|
|
2941
|
+
|
|
2942
|
+
if not QUIET:
|
|
2943
|
+
print()
|
|
2944
|
+
info("Deployed per-tool content:")
|
|
2945
|
+
for tool_id in sorted(deploy_results):
|
|
2946
|
+
w, s, status, _ = deploy_results[tool_id]
|
|
2947
|
+
anchor = USER_SCOPE_PATHS.get(tool_id, "")
|
|
2948
|
+
if status == "deployed":
|
|
2949
|
+
print(f" {tool_id:<15} → {anchor} ({w} files, {s} skipped)")
|
|
2950
|
+
elif status == "marker":
|
|
2951
|
+
print(f" {tool_id:<15} → {anchor}agent-config.md ({'written' if w else 'skipped'})")
|
|
2952
|
+
elif status == "hint":
|
|
2953
|
+
print(f" {tool_id:<15} → no user-scope convention; use `agent-config export --tool={tool_id}`")
|
|
2954
|
+
else:
|
|
2955
|
+
print(f" {tool_id:<15} → no global-scope content yet (project-scope install supported)")
|
|
2956
|
+
if not force and any(s > 0 for _, s, _, _ in deploy_results.values()):
|
|
2957
|
+
info(" Re-run with --force to overwrite existing files.")
|
|
2958
|
+
|
|
2036
2959
|
# Refresh the project-scope manifest when running inside a project tree
|
|
2037
2960
|
# (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
|
|
2038
2961
|
# manifest to write and the global lockfile alone is the source of truth.
|
|
@@ -2044,13 +2967,21 @@ def install_global(
|
|
|
2044
2967
|
and (project_root / SETTINGS_FILE).exists()
|
|
2045
2968
|
and not (project_root / ".agent-src.uncompressed").is_dir()
|
|
2046
2969
|
):
|
|
2047
|
-
|
|
2970
|
+
# Collect deployed/marker paths per tool so the manifest records
|
|
2971
|
+
# the v2 ``files[]`` inventory (P1.4).
|
|
2972
|
+
files_by_tool = _files_by_tool_from_deploy(
|
|
2973
|
+
deploy_results, project_root,
|
|
2974
|
+
)
|
|
2975
|
+
rc = _update_installed_tools_manifest(
|
|
2976
|
+
project_root, tools, "global", force,
|
|
2977
|
+
files_by_tool=files_by_tool,
|
|
2978
|
+
)
|
|
2048
2979
|
if rc != 0:
|
|
2049
2980
|
return rc
|
|
2050
2981
|
|
|
2051
2982
|
if not QUIET:
|
|
2052
2983
|
print()
|
|
2053
|
-
success("Global install
|
|
2984
|
+
success("Global install completed.")
|
|
2054
2985
|
print()
|
|
2055
2986
|
return 0
|
|
2056
2987
|
|
|
@@ -2167,6 +3098,18 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
2167
3098
|
"for --scope=global)."
|
|
2168
3099
|
),
|
|
2169
3100
|
)
|
|
3101
|
+
parser.add_argument(
|
|
3102
|
+
"--offline",
|
|
3103
|
+
action="store_true",
|
|
3104
|
+
help=(
|
|
3105
|
+
"skip every network call: suppress the post-install update "
|
|
3106
|
+
"banner and set AGENT_CONFIG_OFFLINE=1 for downstream "
|
|
3107
|
+
"subprocesses (versions / update / future fetchers). "
|
|
3108
|
+
"All bridge content is bundled in the package, so install "
|
|
3109
|
+
"itself never touches the network — this flag is the "
|
|
3110
|
+
"explicit guarantee for air-gapped / CI runs."
|
|
3111
|
+
),
|
|
3112
|
+
)
|
|
2170
3113
|
opts = parser.parse_args(argv)
|
|
2171
3114
|
opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
|
|
2172
3115
|
if opts.scope == "global" and opts.custom_path:
|
|
@@ -2184,7 +3127,10 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
2184
3127
|
_VALID_TOOLS = {
|
|
2185
3128
|
"claude-code", "claude-desktop", "cursor", "windsurf", "cline",
|
|
2186
3129
|
"gemini-cli", "copilot", "augment", "aider", "codex", "roocode",
|
|
2187
|
-
"continue", "kilocode", "zed", "jetbrains", "kiro",
|
|
3130
|
+
"continue", "kilocode", "zed", "jetbrains", "kiro",
|
|
3131
|
+
# Phase 2.4 expansion (nextlevelbuilder/ui-ux-pro-max-skill anchors).
|
|
3132
|
+
"qoder", "opencode", "trae", "antigravity", "codebuddy", "droid", "warp",
|
|
3133
|
+
"all",
|
|
2188
3134
|
}
|
|
2189
3135
|
|
|
2190
3136
|
|
|
@@ -2231,6 +3177,15 @@ def main(argv: list[str]) -> int:
|
|
|
2231
3177
|
opts = parse_options(argv)
|
|
2232
3178
|
QUIET = opts.quiet
|
|
2233
3179
|
|
|
3180
|
+
# --offline: propagate via env so child subprocesses (versions /
|
|
3181
|
+
# update / check_update_banner) honor the air-gap guarantee
|
|
3182
|
+
# without each one needing its own flag. AGENT_CONFIG_NO_UPDATE_CHECK
|
|
3183
|
+
# is the canonical kill-switch for the post-install banner; the
|
|
3184
|
+
# broader AGENT_CONFIG_OFFLINE signals intent to future fetchers.
|
|
3185
|
+
if opts.offline:
|
|
3186
|
+
os.environ["AGENT_CONFIG_OFFLINE"] = "1"
|
|
3187
|
+
os.environ["AGENT_CONFIG_NO_UPDATE_CHECK"] = "1"
|
|
3188
|
+
|
|
2234
3189
|
if opts.profile not in SUPPORTED_PROFILES:
|
|
2235
3190
|
fail(f"Unsupported profile: {opts.profile}. Supported: {', '.join(SUPPORTED_PROFILES)}")
|
|
2236
3191
|
|
|
@@ -2252,14 +3207,42 @@ def main(argv: list[str]) -> int:
|
|
|
2252
3207
|
tools_was_all = _tools_was_all(opts.tools)
|
|
2253
3208
|
parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
|
|
2254
3209
|
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
3210
|
+
# Conflict policy: load known paths/pointers from the manifest once
|
|
3211
|
+
# so every writer can ask "is this ours?" before clobbering (P3.1 /
|
|
3212
|
+
# P3.3). Built from the project-scope manifest because that's the
|
|
3213
|
+
# only on-disk source of truth across both install scopes.
|
|
3214
|
+
policy_root = detect_root if scope == "global" else (
|
|
3215
|
+
custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
3216
|
+
)
|
|
3217
|
+
_set_conflict_policy(_load_conflict_policy(policy_root, opts.force))
|
|
3218
|
+
|
|
3219
|
+
try:
|
|
3220
|
+
if scope == "global":
|
|
3221
|
+
# Pass detect_root so the manifest refresh runs when --global is
|
|
3222
|
+
# invoked from within a project tree (ADR-008 Phase 3.2).
|
|
3223
|
+
return install_global(parsed_tools, opts.force, project_root=detect_root)
|
|
3224
|
+
|
|
3225
|
+
project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
3226
|
+
is_first_run = not (project_root / SETTINGS_FILE).exists()
|
|
3227
|
+
return _main_project_install(opts, project_root, parsed_tools, is_first_run)
|
|
3228
|
+
except ConflictAbort as exc:
|
|
3229
|
+
warn(exc.message)
|
|
3230
|
+
return 1
|
|
3231
|
+
finally:
|
|
3232
|
+
_set_conflict_policy(None)
|
|
2259
3233
|
|
|
2260
|
-
project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
2261
|
-
is_first_run = not (project_root / SETTINGS_FILE).exists()
|
|
2262
3234
|
|
|
3235
|
+
def _main_project_install(
|
|
3236
|
+
opts: argparse.Namespace,
|
|
3237
|
+
project_root: Path,
|
|
3238
|
+
parsed_tools: set[str],
|
|
3239
|
+
is_first_run: bool,
|
|
3240
|
+
) -> int:
|
|
3241
|
+
"""Project-scope install body extracted from :func:`main`.
|
|
3242
|
+
|
|
3243
|
+
Kept as a private helper so ``main()`` can wrap the entire install
|
|
3244
|
+
in a ``try/except ConflictAbort`` without rewriting indentation.
|
|
3245
|
+
"""
|
|
2263
3246
|
if opts.package:
|
|
2264
3247
|
package_root = Path(opts.package).resolve()
|
|
2265
3248
|
if not (package_root / "config" / "profiles" / "minimal.ini").exists():
|
|
@@ -2282,21 +3265,24 @@ def main(argv: list[str]) -> int:
|
|
|
2282
3265
|
|
|
2283
3266
|
tools = parsed_tools
|
|
2284
3267
|
|
|
3268
|
+
# Per-tool merged_keys collected from JSON bridge merges (P1.5).
|
|
3269
|
+
merged_keys_by_tool: dict[str, list[dict[str, Any]]] = {}
|
|
3270
|
+
|
|
2285
3271
|
if not opts.skip_bridges:
|
|
2286
3272
|
# Substrate bridges (always written; other tools symlink/depend on them).
|
|
2287
3273
|
ensure_vscode_bridge(project_root, package_type, opts.force)
|
|
2288
|
-
ensure_augment_bridge(project_root, opts.force)
|
|
3274
|
+
merged_keys_by_tool["augment"] = ensure_augment_bridge(project_root, opts.force)
|
|
2289
3275
|
# Tool-specific bridges (gated by --tools selection).
|
|
2290
3276
|
if _is_tool_enabled(tools, "claude-code"):
|
|
2291
|
-
ensure_claude_bridge(project_root, opts.force)
|
|
3277
|
+
merged_keys_by_tool["claude-code"] = ensure_claude_bridge(project_root, opts.force)
|
|
2292
3278
|
if _is_tool_enabled(tools, "cursor"):
|
|
2293
|
-
ensure_cursor_bridge(project_root, opts.force)
|
|
3279
|
+
merged_keys_by_tool["cursor"] = ensure_cursor_bridge(project_root, opts.force)
|
|
2294
3280
|
if _is_tool_enabled(tools, "cline"):
|
|
2295
3281
|
ensure_cline_bridge(project_root, opts.force)
|
|
2296
3282
|
if _is_tool_enabled(tools, "windsurf"):
|
|
2297
|
-
ensure_windsurf_bridge(project_root, opts.force)
|
|
3283
|
+
merged_keys_by_tool["windsurf"] = ensure_windsurf_bridge(project_root, opts.force)
|
|
2298
3284
|
if _is_tool_enabled(tools, "gemini-cli"):
|
|
2299
|
-
ensure_gemini_bridge(project_root, opts.force)
|
|
3285
|
+
merged_keys_by_tool["gemini-cli"] = ensure_gemini_bridge(project_root, opts.force)
|
|
2300
3286
|
if _is_tool_enabled(tools, "copilot"):
|
|
2301
3287
|
ensure_copilot_bridge(project_root, opts.force)
|
|
2302
3288
|
if _is_tool_enabled(tools, "roocode"):
|
|
@@ -2318,20 +3304,31 @@ def main(argv: list[str]) -> int:
|
|
|
2318
3304
|
if _is_tool_enabled(tools, "kiro"):
|
|
2319
3305
|
ensure_kiro_bridge(project_root, opts.force)
|
|
2320
3306
|
|
|
3307
|
+
# User-scope hook bridges contribute additional merged_keys to the
|
|
3308
|
+
# same tool entry (P1.5) — the manifest tracks every JSON pointer
|
|
3309
|
+
# the install wrote, regardless of which file owns it.
|
|
2321
3310
|
if opts.augment_user_hooks:
|
|
2322
|
-
|
|
3311
|
+
merged_keys_by_tool.setdefault("augment", []).extend(
|
|
3312
|
+
ensure_augment_user_hooks(package_root, opts.force),
|
|
3313
|
+
)
|
|
2323
3314
|
|
|
2324
3315
|
if opts.cursor_user_hooks and _is_tool_enabled(tools, "cursor"):
|
|
2325
|
-
|
|
3316
|
+
merged_keys_by_tool.setdefault("cursor", []).extend(
|
|
3317
|
+
ensure_cursor_user_hooks(package_root, opts.force),
|
|
3318
|
+
)
|
|
2326
3319
|
|
|
2327
3320
|
if opts.cline_user_hooks and _is_tool_enabled(tools, "cline"):
|
|
2328
3321
|
ensure_cline_user_hooks(package_root, opts.force)
|
|
2329
3322
|
|
|
2330
3323
|
if opts.windsurf_user_hooks and _is_tool_enabled(tools, "windsurf"):
|
|
2331
|
-
|
|
3324
|
+
merged_keys_by_tool.setdefault("windsurf", []).extend(
|
|
3325
|
+
ensure_windsurf_user_hooks(package_root, opts.force),
|
|
3326
|
+
)
|
|
2332
3327
|
|
|
2333
3328
|
if opts.gemini_user_hooks and _is_tool_enabled(tools, "gemini-cli"):
|
|
2334
|
-
|
|
3329
|
+
merged_keys_by_tool.setdefault("gemini-cli", []).extend(
|
|
3330
|
+
ensure_gemini_user_hooks(package_root, opts.force),
|
|
3331
|
+
)
|
|
2335
3332
|
|
|
2336
3333
|
if not opts.skip_bridges and not opts.no_smoke:
|
|
2337
3334
|
if not QUIET:
|
|
@@ -2344,8 +3341,13 @@ def main(argv: list[str]) -> int:
|
|
|
2344
3341
|
# markers actually exist. Skipped when `--skip-bridges` was used (the
|
|
2345
3342
|
# caller is exercising the install plan, not committing to it).
|
|
2346
3343
|
if not opts.skip_bridges:
|
|
3344
|
+
files_by_tool = _files_by_tool_from_bridges(
|
|
3345
|
+
parsed_tools, project_root, "project",
|
|
3346
|
+
)
|
|
2347
3347
|
rc = _update_installed_tools_manifest(
|
|
2348
3348
|
project_root, parsed_tools, "project", opts.force,
|
|
3349
|
+
files_by_tool=files_by_tool,
|
|
3350
|
+
merged_keys_by_tool=merged_keys_by_tool,
|
|
2349
3351
|
)
|
|
2350
3352
|
if rc != 0:
|
|
2351
3353
|
return rc
|