@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.
Files changed (68) hide show
  1. package/.agent-src/commands/onboard.md +14 -9
  2. package/.agent-src/rules/external-reference-deep-dive.md +69 -0
  3. package/.agent-src/skills/ai-council/SKILL.md +5 -3
  4. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  5. package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
  6. package/.agent-src/templates/copilot-instructions.md +7 -0
  7. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
  8. package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
  9. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
  10. package/.claude-plugin/marketplace.json +27 -1
  11. package/CHANGELOG.md +79 -0
  12. package/README.md +1 -8
  13. package/config/agent-settings.template.yml +5 -3
  14. package/docs/architecture.md +1 -1
  15. package/docs/catalog.md +5 -3
  16. package/docs/contracts/installed-tools-lockfile.md +142 -0
  17. package/docs/customization.md +23 -17
  18. package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
  19. package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
  20. package/docs/decisions/INDEX.md +1 -0
  21. package/docs/development.md +37 -0
  22. package/docs/getting-started.md +1 -1
  23. package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
  24. package/docs/guidelines/agent-infra/layered-settings.md +6 -4
  25. package/docs/installation.md +17 -2
  26. package/docs/migration/v1-to-v2.md +45 -0
  27. package/docs/setup/per-ide/antigravity.md +63 -0
  28. package/docs/setup/per-ide/augment.md +77 -0
  29. package/docs/setup/per-ide/claude-desktop.md +107 -65
  30. package/docs/setup/per-ide/codebuddy.md +63 -0
  31. package/docs/setup/per-ide/continue.md +68 -0
  32. package/docs/setup/per-ide/droid.md +65 -0
  33. package/docs/setup/per-ide/jetbrains.md +76 -0
  34. package/docs/setup/per-ide/kilocode.md +66 -0
  35. package/docs/setup/per-ide/kiro.md +72 -0
  36. package/docs/setup/per-ide/opencode.md +62 -0
  37. package/docs/setup/per-ide/qoder.md +63 -0
  38. package/docs/setup/per-ide/roocode.md +68 -0
  39. package/docs/setup/per-ide/trae.md +63 -0
  40. package/docs/setup/per-ide/warp.md +63 -0
  41. package/docs/setup/per-ide/zed.md +73 -0
  42. package/package.json +1 -1
  43. package/scripts/_cli/cmd_doctor.py +351 -0
  44. package/scripts/_cli/cmd_prune.py +317 -0
  45. package/scripts/_cli/cmd_uninstall.py +465 -0
  46. package/scripts/_cli/cmd_update.py +30 -4
  47. package/scripts/_cli/cmd_versions.py +147 -0
  48. package/scripts/_lib/agent_settings.py +29 -7
  49. package/scripts/_lib/agents_overlay.py +15 -4
  50. package/scripts/_lib/claude_desktop_bundler.py +150 -0
  51. package/scripts/_lib/fs_atomic.py +116 -0
  52. package/scripts/_lib/installed_lock.py +37 -4
  53. package/scripts/_lib/installed_tools.py +189 -45
  54. package/scripts/_lib/json_pointers.py +260 -0
  55. package/scripts/_lib/update_check.py +29 -5
  56. package/scripts/_lib/user_global_paths.py +249 -0
  57. package/scripts/agent-config +69 -0
  58. package/scripts/ai_council/__init__.py +4 -3
  59. package/scripts/ai_council/budget_guard.py +34 -4
  60. package/scripts/ai_council/bundler.py +2 -0
  61. package/scripts/ai_council/clients.py +28 -7
  62. package/scripts/compress.py +78 -15
  63. package/scripts/install +8 -0
  64. package/scripts/install-hooks.sh +54 -1
  65. package/scripts/install.py +1149 -53
  66. package/scripts/install_anthropic_key.sh +5 -3
  67. package/scripts/install_openai_key.sh +5 -3
  68. package/scripts/skill_trigger_eval.py +13 -2
@@ -0,0 +1,249 @@
1
+ """Vendor-namespaced user-global path resolution.
2
+
3
+ Phase 1 of road-to-event4u-namespace-and-claude-desktop.md. Single source
4
+ of truth for "where does this package keep user-global state on disk?".
5
+ Replaces hard-coded ``~/.config/agent-config/`` literals scattered across
6
+ ``scripts/_lib`` and ``scripts/ai_council``.
7
+
8
+ Resolution order:
9
+
10
+ 1. ``$EVENT4U_CONFIG_HOME`` — full path override (testing + power users).
11
+ 2. ``~/.event4u/agent-config/`` — vendor-namespaced source-of-truth.
12
+
13
+ For backward compatibility during the transition, ``legacy_xdg_root()``
14
+ exposes the old ``~/.config/agent-config/`` path so loaders can read
15
+ state written by pre-2.4 installs. Writers should never target the
16
+ legacy path; the auto-migration shim (Phase 3) copies state once into
17
+ the new namespace.
18
+
19
+ Contract — pure, read-only, never auto-creates directories.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ #: Marker suffix for in-progress entry copies during migration. A copy
29
+ #: that crashes mid-flight leaves ``<name><suffix><pid>`` behind so the
30
+ #: next run can clean it up before retrying — instead of treating a
31
+ #: partial subdir as a completed copy.
32
+ _PARTIAL_SUFFIX = ".event4u-partial-"
33
+
34
+ #: Environment variable that overrides ``event4u_root()`` outright.
35
+ #: Accepts a full path (``~`` expanded). Primarily used by tests; power
36
+ #: users may also point this at a custom location.
37
+ EVENT4U_HOME_ENV = "EVENT4U_CONFIG_HOME"
38
+
39
+ #: Vendor-namespaced default. Relative to the user's home directory.
40
+ DEFAULT_EVENT4U_ROOT_RELATIVE = Path(".event4u") / "agent-config"
41
+
42
+ #: Legacy XDG-shaped default written by pre-2.4 installs. Read-only
43
+ #: fallback during the transition; never the target of a write.
44
+ LEGACY_XDG_ROOT_RELATIVE = Path(".config") / "agent-config"
45
+
46
+
47
+ def event4u_root(env: Optional[dict] = None) -> Path:
48
+ """Return the active user-global root directory.
49
+
50
+ Honours ``EVENT4U_CONFIG_HOME`` first, falls back to
51
+ ``~/.event4u/agent-config/``. Never creates the directory.
52
+ """
53
+ env_map = env if env is not None else os.environ
54
+ override = env_map.get(EVENT4U_HOME_ENV)
55
+ if override:
56
+ return Path(override).expanduser()
57
+ return Path.home() / DEFAULT_EVENT4U_ROOT_RELATIVE
58
+
59
+
60
+ def legacy_xdg_root() -> Path:
61
+ """Return the pre-2.4 user-global root at ``~/.config/agent-config/``.
62
+
63
+ Used by loaders during the transition to read settings, lockfiles,
64
+ and keys written before the namespace migration ran. Writers MUST
65
+ NOT target this path — only ``event4u_root()`` is a valid write
66
+ target. Never creates the directory.
67
+ """
68
+ return Path.home() / LEGACY_XDG_ROOT_RELATIVE
69
+
70
+
71
+ def resolve_with_fallback(
72
+ relative_name: str,
73
+ *,
74
+ env: Optional[dict] = None,
75
+ ) -> Optional[Path]:
76
+ """Resolve a named file/dir under the user-global root, with legacy fallback.
77
+
78
+ Returns the new-namespace path if it exists on disk, otherwise the
79
+ legacy XDG path if it exists, otherwise ``None``. Callers that need
80
+ the *write target* (regardless of existence) should use
81
+ ``event4u_root() / relative_name`` directly.
82
+
83
+ ``relative_name`` is a forward-slash separated string (e.g.
84
+ ``"installed.lock"`` or ``"agents/global"``). It is treated as a
85
+ path fragment relative to the chosen root; absolute paths are
86
+ rejected with ``ValueError``.
87
+ """
88
+ fragment = Path(relative_name)
89
+ if fragment.is_absolute():
90
+ raise ValueError(
91
+ f"resolve_with_fallback expects a relative path, got {relative_name!r}"
92
+ )
93
+ new_path = event4u_root(env=env) / fragment
94
+ if new_path.exists():
95
+ return new_path
96
+ legacy_path = legacy_xdg_root() / fragment
97
+ if legacy_path.exists():
98
+ return legacy_path
99
+ return None
100
+
101
+
102
+ def write_target(relative_name: str, *, env: Optional[dict] = None) -> Path:
103
+ """Return the canonical write target for a named user-global file/dir.
104
+
105
+ Always rooted at ``event4u_root()`` — writers never target the
106
+ legacy XDG path. Caller is responsible for ``mkdir(parents=True)``
107
+ on the parent before writing. Never creates the directory itself.
108
+ """
109
+ fragment = Path(relative_name)
110
+ if fragment.is_absolute():
111
+ raise ValueError(
112
+ f"write_target expects a relative path, got {relative_name!r}"
113
+ )
114
+ return event4u_root(env=env) / fragment
115
+
116
+
117
+ #: Breadcrumb dropped into the legacy root after a successful migration.
118
+ #: Tells the user where their state now lives and how to clean up. The
119
+ #: legacy tree itself is never auto-deleted — only the user does that.
120
+ MIGRATION_BREADCRUMB_NAME = "MIGRATED.md"
121
+
122
+ _BREADCRUMB_TEMPLATE = """# Migrated to `~/.event4u/agent-config/`
123
+
124
+ This directory (`~/.config/agent-config/`) is the **legacy** location
125
+ for `event4u/agent-config` user-global state. As of v2.4 the canonical
126
+ location is `~/.event4u/agent-config/`.
127
+
128
+ The migration shim has already copied your settings, keys, lockfiles,
129
+ and overrides into the new namespace. File modes (0600 on keys) were
130
+ preserved. Loaders prefer the new path but still read from this tree
131
+ as a fallback, so removing it is safe **once you've confirmed** the
132
+ new location is working.
133
+
134
+ ## To clean up
135
+
136
+ ```bash
137
+ rm -rf ~/.config/agent-config
138
+ ```
139
+
140
+ ## Why the move
141
+
142
+ `~/.config/` is a generic XDG-shaped directory shared by many tools.
143
+ `~/.event4u/agent-config/` is vendor-namespaced and avoids collisions
144
+ with unrelated CLIs. See
145
+ `agents/roadmaps/road-to-event4u-namespace-and-claude-desktop.md` for
146
+ the full rationale.
147
+ """
148
+
149
+
150
+ def migrate_legacy_namespace(
151
+ *,
152
+ env: Optional[dict] = None,
153
+ legacy_root_override: Optional[Path] = None,
154
+ ) -> bool:
155
+ """Copy pre-2.4 user-global state from legacy XDG root into the new namespace.
156
+
157
+ Idempotent and safe to call on every install / init. Returns ``True``
158
+ if a copy ran during this invocation, ``False`` when the migration
159
+ was already complete or there was nothing to migrate.
160
+
161
+ Contract:
162
+
163
+ - Never auto-deletes the legacy tree — that's the user's call (the
164
+ breadcrumb at ``~/.config/agent-config/MIGRATED.md`` documents it).
165
+ - Preserves file modes via ``shutil.copytree(..., copy_function=copy2)``
166
+ so 0600 key files stay 0600 after the copy.
167
+ - If the new root already exists with any content, the migration
168
+ treats it as already-done and only writes the breadcrumb (if
169
+ missing) — never overwrites new-namespace state.
170
+ - If the legacy root is missing or empty, the function is a no-op.
171
+ - Per-entry atomic write: each entry is copied to a sibling
172
+ ``<name>.event4u-partial-<pid>`` and then ``os.replace``'d into
173
+ the final name. If a previous run crashed mid-``copytree``, the
174
+ leftover ``*.event4u-partial-*`` siblings are cleaned up at the
175
+ top of the next run before retrying — a partial directory is
176
+ never mistaken for a completed copy.
177
+
178
+ ``legacy_root_override`` is for tests; production callers leave it ``None``.
179
+ """
180
+ legacy_root = (
181
+ legacy_root_override if legacy_root_override is not None else legacy_xdg_root()
182
+ )
183
+ new_root = event4u_root(env=env)
184
+
185
+ if not legacy_root.exists() or not legacy_root.is_dir():
186
+ return False
187
+
188
+ # Skip the migrated-breadcrumb itself when checking for content so a
189
+ # second invocation does not loop on its own marker.
190
+ legacy_entries = [
191
+ p for p in legacy_root.iterdir() if p.name != MIGRATION_BREADCRUMB_NAME
192
+ ]
193
+ if not legacy_entries:
194
+ return False
195
+
196
+ # Real content check ignores partial-copy debris from a prior
197
+ # interrupted run; otherwise the breadcrumb would be written for
198
+ # a half-finished migration and retry would never run.
199
+ new_has_content = new_root.exists() and any(
200
+ not _is_partial_entry(p) for p in new_root.iterdir()
201
+ )
202
+ if new_has_content:
203
+ _ensure_breadcrumb(legacy_root)
204
+ return False
205
+
206
+ new_root.mkdir(parents=True, exist_ok=True)
207
+ _purge_partial_entries(new_root)
208
+
209
+ for entry in legacy_entries:
210
+ target = new_root / entry.name
211
+ if target.exists():
212
+ continue
213
+ staging = new_root / f"{entry.name}{_PARTIAL_SUFFIX}{os.getpid()}"
214
+ if staging.exists():
215
+ _remove_path(staging)
216
+ if entry.is_dir():
217
+ shutil.copytree(entry, staging, copy_function=shutil.copy2)
218
+ else:
219
+ shutil.copy2(entry, staging)
220
+ os.replace(staging, target)
221
+
222
+ _ensure_breadcrumb(legacy_root)
223
+ return True
224
+
225
+
226
+ def _is_partial_entry(path: Path) -> bool:
227
+ return _PARTIAL_SUFFIX in path.name
228
+
229
+
230
+ def _purge_partial_entries(new_root: Path) -> None:
231
+ """Remove ``*.event4u-partial-*`` leftovers from a previous interrupted run."""
232
+ for entry in new_root.iterdir():
233
+ if _is_partial_entry(entry):
234
+ _remove_path(entry)
235
+
236
+
237
+ def _remove_path(path: Path) -> None:
238
+ if path.is_dir() and not path.is_symlink():
239
+ shutil.rmtree(path)
240
+ else:
241
+ path.unlink(missing_ok=True)
242
+
243
+
244
+ def _ensure_breadcrumb(legacy_root: Path) -> None:
245
+ """Write the ``MIGRATED.md`` breadcrumb into ``legacy_root`` if absent."""
246
+ breadcrumb = legacy_root / MIGRATION_BREADCRUMB_NAME
247
+ if breadcrumb.exists():
248
+ return
249
+ breadcrumb.write_text(_BREADCRUMB_TEMPLATE, encoding="utf-8")
@@ -114,6 +114,24 @@ Commands:
114
114
  validate Read-only drift detection on the manifest
115
115
  (marker missing, scope divergence, version drift).
116
116
  Exits 1 on drift. Flags: --quiet | --skip-version-check
117
+ uninstall Remove bridge markers (project) or lockfile
118
+ entries (global). Idempotent. User-deployed
119
+ content under ~/.<tool>/ is preserved unless
120
+ --purge is passed (destructive).
121
+ Flags: --global | --tools=<list> | --dry-run
122
+ | --purge | --force | --project=<path>
123
+ prune Remove project bridge markers not declared in
124
+ agents/installed-tools.lock (npm-prune style).
125
+ Hard-floors when lockfile is absent.
126
+ Flags: --dry-run | --json | --project=<path>
127
+ | --all-missing-lock
128
+ doctor Read-only drift report: manifest ↔ filesystem.
129
+ Lists missing, modified, and foreign files.
130
+ Exits 1 on drift, 2 on missing lockfile.
131
+ Flags: --json | --project=<path>
132
+ versions List available @event4u/agent-config versions
133
+ on npm. Marks the current pin and latest.
134
+ Flags: --offline | --limit=N | --json
117
135
  help Show this help
118
136
  --version, -V Print package version
119
137
 
@@ -151,6 +169,17 @@ Examples:
151
169
  ./agent-config sync --dry-run
152
170
  ./agent-config sync
153
171
  ./agent-config validate
172
+ ./agent-config uninstall --tools=cursor --dry-run
173
+ ./agent-config uninstall --global --tools=windsurf --purge
174
+ ./agent-config prune --dry-run
175
+ ./agent-config prune --json
176
+ ./agent-config doctor
177
+ ./agent-config doctor --json
178
+ ./agent-config versions
179
+ ./agent-config versions --limit=10
180
+ ./agent-config versions --json
181
+ ./agent-config init --offline --tools=claude-code,cursor --yes
182
+ ./agent-config update --offline --to=2.2.0
154
183
 
155
184
  All commands operate on the CURRENT DIRECTORY (your project root).
156
185
  The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
@@ -596,6 +625,42 @@ cmd_validate() {
596
625
  exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
597
626
  }
598
627
 
628
+ # `agent-config uninstall` — remove bridge markers (project) or lockfile
629
+ # entries (global). Idempotent. Pass `--purge` to also delete deployed
630
+ # content directories under user-scope anchors (destructive). See
631
+ # scripts/_cli/cmd_uninstall.py.
632
+ cmd_uninstall() {
633
+ require_python3
634
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_uninstall "$@"
635
+ }
636
+
637
+ # `agent-config prune` — remove orphaned project bridge markers.
638
+ # Drift-cleanup sibling to `uninstall`: compares on-disk markers
639
+ # against agents/installed-tools.lock and unlinks anything not
640
+ # declared. Hard-floors when lockfile is absent. See
641
+ # scripts/_cli/cmd_prune.py.
642
+ cmd_prune() {
643
+ require_python3
644
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_prune "$@"
645
+ }
646
+
647
+ # `agent-config doctor` — read-only drift report against the manifest.
648
+ # Surfaces missing / modified / foreign files. Exit 0 clean, 1 drift,
649
+ # 2 manifest-absent. See scripts/_cli/cmd_doctor.py.
650
+ cmd_doctor() {
651
+ require_python3
652
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_doctor "$@"
653
+ }
654
+
655
+ # `agent-config versions` — list available @event4u/agent-config versions
656
+ # on the npm registry. Marks the current pin (from .agent-settings.yml)
657
+ # and the latest published version. Offline-tolerant. See
658
+ # scripts/_cli/cmd_versions.py.
659
+ cmd_versions() {
660
+ require_python3
661
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_versions "$@"
662
+ }
663
+
599
664
  main() {
600
665
  local cmd="${1-}"
601
666
  [[ $# -gt 0 ]] && shift || true
@@ -641,6 +706,10 @@ main() {
641
706
  export) cmd_export "$@" ;;
642
707
  sync) cmd_sync "$@" ;;
643
708
  validate) cmd_validate "$@" ;;
709
+ uninstall) cmd_uninstall "$@" ;;
710
+ prune) cmd_prune "$@" ;;
711
+ doctor) cmd_doctor "$@" ;;
712
+ versions) cmd_versions "$@" ;;
644
713
  help|--help|-h|"") usage ;;
645
714
  --version|-V) print_version ;;
646
715
  *)
@@ -13,9 +13,10 @@ Architecture:
13
13
  prompts.py — Neutrality system-prompt templates per input mode.
14
14
 
15
15
  Trust boundary: this module makes networked, paid calls. Tokens come
16
- exclusively from ~/.config/agent-config/<provider>.key (mode 0600). The
17
- module never edits files, never opens PRs, never merges — output is
18
- text only, advisory.
16
+ exclusively from ``~/.event4u/agent-config/<provider>.key`` (mode 0600;
17
+ legacy ``~/.config/agent-config/<provider>.key`` is read as a fallback
18
+ for pre-2.4 installs). The module never edits files, never opens PRs,
19
+ never merges — output is text only, advisory.
19
20
  """
20
21
 
21
22
  from scripts.ai_council.clients import (
@@ -2,8 +2,12 @@
2
2
 
3
3
  Adds a 24h-rolling-window USD limit on top of the per-session caps in
4
4
  `orchestrator.CostBudget`. Persists a small JSONL ledger in
5
- ``~/.config/agent-config/council-spend.jsonl`` (mode 0600, same
6
- permission discipline as the API keys).
5
+ ``~/.event4u/agent-config/council-spend.jsonl`` (mode 0600, same
6
+ permission discipline as the API keys). The legacy
7
+ ``~/.config/agent-config/council-spend.jsonl`` is read as a fallback so
8
+ spend history accumulated under a pre-2.4 install is preserved across
9
+ the namespace migration; new entries are always appended to the new
10
+ path.
7
11
 
8
12
  Contract
9
13
  - The ledger is **append-only**. Each line is ``{"ts": ISO-8601 UTC,
@@ -31,10 +35,34 @@ import sys
31
35
  from dataclasses import dataclass
32
36
  from pathlib import Path
33
37
 
34
- LEDGER_PATH = Path.home() / ".config" / "agent-config" / "council-spend.jsonl"
38
+ from scripts._lib import user_global_paths
39
+
40
+ LEDGER_FILENAME = "council-spend.jsonl"
41
+
42
+ #: Canonical write target under the new namespace. Reads route via
43
+ #: :func:`_resolve_ledger_path` so a ledger still sitting in the legacy
44
+ #: ``~/.config/agent-config/`` tree keeps contributing to the rolling
45
+ #: window until the user migrates.
46
+ LEDGER_PATH = user_global_paths.write_target(LEDGER_FILENAME)
35
47
  ROLLING_WINDOW_HOURS = 24
36
48
 
37
49
 
50
+ def _resolve_ledger_path(path: Path | None) -> Path:
51
+ """Return the active ledger path, preferring the new namespace.
52
+
53
+ A caller-supplied ``path`` always wins (tests pin a tmp file). When
54
+ no override is given we prefer the new namespace, then fall back to
55
+ the legacy location if a ledger file already exists there. New
56
+ writes always target the new namespace via :data:`LEDGER_PATH`.
57
+ """
58
+ if path is not None:
59
+ return path
60
+ found = user_global_paths.resolve_with_fallback(LEDGER_FILENAME)
61
+ if found is not None:
62
+ return found
63
+ return LEDGER_PATH
64
+
65
+
38
66
  @dataclass
39
67
  class SpendEntry:
40
68
  ts: _dt.datetime # UTC, tz-aware
@@ -87,8 +115,10 @@ def read_entries(path: Path | None = None) -> list[SpendEntry]:
87
115
  """Read every well-formed entry from the ledger.
88
116
 
89
117
  Malformed lines are skipped silently. Empty/missing ledger → [].
118
+ Reads route through :func:`_resolve_ledger_path` so a legacy ledger
119
+ keeps contributing to the rolling window until the user migrates.
90
120
  """
91
- p = path or LEDGER_PATH
121
+ p = _resolve_ledger_path(path)
92
122
  if not p.exists():
93
123
  return []
94
124
  out: list[SpendEntry] = []
@@ -38,6 +38,8 @@ class CouncilContext:
38
38
  # placeholder. Order matters — the most specific pattern goes first.
39
39
 
40
40
  _REDACTION_LINE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
41
+ (re.compile(r"~?/?\.event4u/agent-config/[^/\s]+\.key"),
42
+ "[redacted: agent-config key path]"),
41
43
  (re.compile(r"~?/?\.config/agent-config/[^/\s]+\.key"),
42
44
  "[redacted: agent-config key path]"),
43
45
  (re.compile(r"^\s*Authorization:\s", re.IGNORECASE),
@@ -1,7 +1,10 @@
1
1
  """External-AI clients for the council.
2
2
 
3
3
  Mirrors the contract from `scripts/skill_trigger_eval.py`:
4
- - Tokens come exclusively from ~/.config/agent-config/<provider>.key.
4
+ - Tokens come exclusively from ``~/.event4u/agent-config/<provider>.key``
5
+ (legacy ``~/.config/agent-config/<provider>.key`` is read as a
6
+ fallback so pre-2.4 installs keep working until the user moves the
7
+ files into the new namespace).
5
8
  - File mode must be exactly 0o600. Drift is a hard abort.
6
9
  - No environment-variable fallback. No keychain fallback.
7
10
  - Real SDKs (`anthropic`, `openai`) are *soft* dependencies — the
@@ -28,8 +31,24 @@ from dataclasses import dataclass, field
28
31
  from pathlib import Path
29
32
  from typing import TextIO
30
33
 
31
- ANTHROPIC_KEY_PATH = Path.home() / ".config" / "agent-config" / "anthropic.key"
32
- OPENAI_KEY_PATH = Path.home() / ".config" / "agent-config" / "openai.key"
34
+ from scripts._lib import user_global_paths
35
+
36
+ ANTHROPIC_KEY_FILENAME = "anthropic.key"
37
+ OPENAI_KEY_FILENAME = "openai.key"
38
+
39
+ #: Canonical write target under the new namespace. Reads route via
40
+ #: :func:`_resolve_key_path` so a key still sitting in the legacy
41
+ #: ``~/.config/agent-config/`` tree keeps working.
42
+ ANTHROPIC_KEY_PATH = user_global_paths.write_target(ANTHROPIC_KEY_FILENAME)
43
+ OPENAI_KEY_PATH = user_global_paths.write_target(OPENAI_KEY_FILENAME)
44
+
45
+
46
+ def _resolve_key_path(filename: str) -> Path:
47
+ """Return the active key path, preferring the new namespace."""
48
+ found = user_global_paths.resolve_with_fallback(filename)
49
+ if found is not None:
50
+ return found
51
+ return user_global_paths.write_target(filename)
33
52
 
34
53
  DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-5"
35
54
  DEFAULT_OPENAI_MODEL = "gpt-4o"
@@ -87,12 +106,14 @@ def _load_key(path: Path, prefix: str, install_script: str) -> str:
87
106
  return key
88
107
 
89
108
 
90
- def load_anthropic_key(path: Path = ANTHROPIC_KEY_PATH) -> str:
91
- return _load_key(path, "sk-ant-", "scripts/install_anthropic_key.sh")
109
+ def load_anthropic_key(path: Path | None = None) -> str:
110
+ resolved = path if path is not None else _resolve_key_path(ANTHROPIC_KEY_FILENAME)
111
+ return _load_key(resolved, "sk-ant-", "scripts/install_anthropic_key.sh")
92
112
 
93
113
 
94
- def load_openai_key(path: Path = OPENAI_KEY_PATH) -> str:
95
- return _load_key(path, "sk-", "scripts/install_openai_key.sh")
114
+ def load_openai_key(path: Path | None = None) -> str:
115
+ resolved = path if path is not None else _resolve_key_path(OPENAI_KEY_FILENAME)
116
+ return _load_key(resolved, "sk-", "scripts/install_openai_key.sh")
96
117
 
97
118
 
98
119
  class ExternalAIClient(ABC):
@@ -19,6 +19,8 @@ Usage:
19
19
  python scripts/compress.py --project-augment # rebuild .augment/ projection
20
20
  """
21
21
 
22
+ from __future__ import annotations
23
+
22
24
  import hashlib
23
25
  import json
24
26
  import re
@@ -38,6 +40,40 @@ AUGMENT_DIR = PROJECT_ROOT / ".augment"
38
40
  HASH_FILE = PROJECT_ROOT / ".compression-hashes.json"
39
41
  SETTINGS_FILE = PROJECT_ROOT / ".agent-settings.yml"
40
42
 
43
+ # Self-projection tool toggle — see .agent-tools.yml. When the file is
44
+ # absent (e.g. tests run in tmp dirs, consumer projects), `_active_tools`
45
+ # returns ``None`` which is treated as "emit every tool".
46
+ _ALL_TOOLS = frozenset({
47
+ "claude-code", "claude-desktop", "augment", "copilot",
48
+ "cursor", "windsurf", "cline", "gemini",
49
+ })
50
+
51
+
52
+ def _active_tools() -> frozenset[str] | None:
53
+ """Return the set of active self-projection tools, or None for "all".
54
+
55
+ Reads `.agent-tools.yml` relative to the current `PROJECT_ROOT` so
56
+ test fixtures that monkey-patch `compress.PROJECT_ROOT` see their own
57
+ (empty) project root and get the default "all tools" behaviour.
58
+ """
59
+ tools_file = PROJECT_ROOT / ".agent-tools.yml"
60
+ if not tools_file.exists():
61
+ return None
62
+ try:
63
+ data = yaml.safe_load(tools_file.read_text()) or {}
64
+ except yaml.YAMLError:
65
+ return None
66
+ tools = data.get("tools") if isinstance(data, dict) else None
67
+ if not isinstance(tools, list):
68
+ return None
69
+ return frozenset(str(t) for t in tools if isinstance(t, str))
70
+
71
+
72
+ def _tool_active(tool_id: str) -> bool:
73
+ """True when ``tool_id`` should be emitted by self-projection."""
74
+ active = _active_tools()
75
+ return True if active is None else tool_id in active
76
+
41
77
  # Files to copy as-is even if .md (not compressed by agent)
42
78
  COPY_AS_IS = {"README.md"}
43
79
 
@@ -306,6 +342,24 @@ PERSONA_TOOL_DIRS = {
306
342
  ".cursor/personas": "../../.agent-src/personas",
307
343
  }
308
344
 
345
+ # Map tool-projection directories to the canonical tool ID used by
346
+ # `.agent-tools.yml`. Directories not in this map are always emitted.
347
+ _DIR_TOOL_ID = {
348
+ ".claude/rules": "claude-code",
349
+ ".cursor/rules": "cursor",
350
+ ".clinerules": "cline",
351
+ ".claude/personas": "claude-code",
352
+ ".cursor/personas": "cursor",
353
+ }
354
+
355
+
356
+ def _filter_tool_dirs(mapping: dict[str, str]) -> dict[str, str]:
357
+ """Drop entries whose tool ID is not active in `.agent-tools.yml`."""
358
+ return {
359
+ d: p for d, p in mapping.items()
360
+ if _tool_active(_DIR_TOOL_ID.get(d, "claude-code"))
361
+ }
362
+
309
363
 
310
364
  def strip_frontmatter(content: str) -> str:
311
365
  """Remove YAML frontmatter (between --- markers) from content."""
@@ -461,8 +515,9 @@ def generate_rule_symlinks() -> int:
461
515
  """
462
516
  # All .md files in .agent-src/rules/ — not just universal ones
463
517
  rules = sorted([f.name for f in RULES_SOURCE.glob("*.md")])
518
+ tool_dirs = _filter_tool_dirs(TOOL_DIRS)
464
519
  total = 0
465
- for tool_dir, rel_prefix in TOOL_DIRS.items():
520
+ for tool_dir, rel_prefix in tool_dirs.items():
466
521
  target_dir = PROJECT_ROOT / tool_dir
467
522
  target_dir.mkdir(parents=True, exist_ok=True)
468
523
 
@@ -481,13 +536,13 @@ def generate_rule_symlinks() -> int:
481
536
 
482
537
  # Verify counts match across all tool directories
483
538
  source_count = len(rules)
484
- for tool_dir in TOOL_DIRS:
539
+ for tool_dir in tool_dirs:
485
540
  target_dir = PROJECT_ROOT / tool_dir
486
541
  tool_count = len([f for f in target_dir.iterdir() if f.is_symlink() and f.suffix == ".md"])
487
542
  if tool_count != source_count:
488
543
  print(f" ⚠️ {tool_dir}: {tool_count} rules (expected {source_count})")
489
544
 
490
- info(f" ✅ Created {total} rule symlinks across {len(TOOL_DIRS)} tool directories ({source_count} rules each)")
545
+ info(f" ✅ Created {total} rule symlinks across {len(tool_dirs)} tool directories ({source_count} rules each)")
491
546
  return total
492
547
 
493
548
 
@@ -812,8 +867,9 @@ def generate_persona_symlinks() -> int:
812
867
  personas = sorted([
813
868
  f.name for f in PERSONAS_SOURCE.glob("*.md") if f.stem != "README"
814
869
  ])
870
+ tool_dirs = _filter_tool_dirs(PERSONA_TOOL_DIRS)
815
871
  total = 0
816
- for tool_dir, rel_prefix in PERSONA_TOOL_DIRS.items():
872
+ for tool_dir, rel_prefix in tool_dirs.items():
817
873
  target_dir = PROJECT_ROOT / tool_dir
818
874
  target_dir.mkdir(parents=True, exist_ok=True)
819
875
 
@@ -830,28 +886,35 @@ def generate_persona_symlinks() -> int:
830
886
  link.symlink_to(target)
831
887
  total += 1
832
888
 
833
- info(f" ✅ Created {total} persona symlinks across {len(PERSONA_TOOL_DIRS)} tool directories ({len(personas)} personas each)")
889
+ info(f" ✅ Created {total} persona symlinks across {len(tool_dirs)} tool directories ({len(personas)} personas each)")
834
890
  return total
835
891
 
836
892
 
837
893
  def generate_tools() -> None:
838
- """Generate all tool-specific directories and files."""
894
+ """Generate all tool-specific directories and files.
895
+
896
+ `.agent-tools.yml` (top-level) gates per-tool emission. When the file
897
+ is missing, every tool is emitted (preserves test fixtures and
898
+ pre-gating behaviour). See `_active_tools()` and `_tool_active()`.
899
+ """
839
900
  info("🔧 Generating multi-agent tool directories...\n")
840
901
  rules = generate_rule_symlinks()
841
- generate_windsurfrules()
842
- generate_gemini_md()
843
- skills = generate_claude_skills()
844
- commands = generate_claude_commands()
902
+ windsurfrules = generate_windsurfrules() if _tool_active("windsurf") else 0
903
+ if _tool_active("gemini"):
904
+ generate_gemini_md()
905
+ skills = generate_claude_skills() if _tool_active("claude-code") else 0
906
+ commands = generate_claude_commands() if _tool_active("claude-code") else 0
845
907
  personas = generate_persona_symlinks()
846
- cursor_mdc = generate_cursor_mdc_rules()
847
- windsurf_modern = generate_windsurf_modern_rules()
848
- cursor_cmds = generate_cursor_commands()
849
- windsurf_wf = generate_windsurf_workflows()
908
+ cursor_mdc = generate_cursor_mdc_rules() if _tool_active("cursor") else 0
909
+ windsurf_modern = generate_windsurf_modern_rules() if _tool_active("windsurf") else 0
910
+ cursor_cmds = generate_cursor_commands() if _tool_active("cursor") else 0
911
+ windsurf_wf = generate_windsurf_workflows() if _tool_active("windsurf") else 0
850
912
  summary = (
851
913
  f"✅ generate-tools — rules={rules} skills={skills} "
852
914
  f"commands={commands} personas={personas} "
853
915
  f"cursor_mdc={cursor_mdc} windsurf_rules={windsurf_modern} "
854
- f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf}"
916
+ f"cursor_commands={cursor_cmds} windsurf_workflows={windsurf_wf} "
917
+ f"windsurfrules={windsurfrules}"
855
918
  )
856
919
  if resolve_level() == "verbose":
857
920
  print(f"\n{summary}")