@event4u/agent-config 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/onboard.md +14 -9
- package/.agent-src/skills/ai-council/SKILL.md +5 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
- package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +30 -0
- package/config/agent-settings.template.yml +5 -3
- package/docs/catalog.md +5 -3
- package/docs/contracts/installed-tools-lockfile.md +4 -0
- package/docs/customization.md +23 -17
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
- package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +6 -4
- package/docs/installation.md +3 -2
- package/docs/migration/v1-to-v2.md +45 -0
- package/docs/setup/per-ide/claude-desktop.md +107 -65
- package/package.json +1 -1
- package/scripts/_cli/cmd_uninstall.py +1 -1
- package/scripts/_cli/cmd_update.py +4 -1
- package/scripts/_lib/agent_settings.py +29 -7
- package/scripts/_lib/agents_overlay.py +15 -4
- package/scripts/_lib/claude_desktop_bundler.py +150 -0
- package/scripts/_lib/installed_lock.py +37 -4
- package/scripts/_lib/installed_tools.py +1 -1
- package/scripts/_lib/update_check.py +29 -5
- package/scripts/_lib/user_global_paths.py +249 -0
- package/scripts/ai_council/__init__.py +4 -3
- package/scripts/ai_council/budget_guard.py +34 -4
- package/scripts/ai_council/bundler.py +2 -0
- package/scripts/ai_council/clients.py +28 -7
- package/scripts/install.py +117 -23
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- 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")
|
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
``~/.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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 =
|
|
91
|
-
|
|
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 =
|
|
95
|
-
|
|
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):
|
package/scripts/install.py
CHANGED
|
@@ -2158,17 +2158,34 @@ _CLAUDE_DESKTOP_MARKER_TEMPLATE = """\
|
|
|
2158
2158
|
|
|
2159
2159
|
Installed by `@event4u/agent-config` (user scope, ADR-007).
|
|
2160
2160
|
|
|
2161
|
-
- Lockfile:
|
|
2162
|
-
- Anchor:
|
|
2161
|
+
- Lockfile: `{lockfile}`
|
|
2162
|
+
- Anchor: `{anchor}`
|
|
2163
|
+
- Skill bundles: `{bundles_dir}` ({bundle_count} ZIPs)
|
|
2163
2164
|
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2165
|
+
## Import skills into Claude Desktop
|
|
2166
|
+
|
|
2167
|
+
Claude Desktop has no filesystem skill-discovery convention — skills are
|
|
2168
|
+
imported manually via the Customize → Skills UI.
|
|
2169
|
+
|
|
2170
|
+
1. Open Claude Desktop → **Settings → Customize → Skills**.
|
|
2171
|
+
2. Click the **Upload skill** button.
|
|
2172
|
+
3. Browse to `{bundles_dir}` and pick the `<skill-name>.zip` files you
|
|
2173
|
+
want to install. One ZIP = one skill.
|
|
2174
|
+
4. Repeat per skill. Claude Desktop keeps each upload until you remove it.
|
|
2175
|
+
|
|
2176
|
+
The bundle directory is regenerated on every
|
|
2177
|
+
`npx @event4u/agent-config init --tools=claude-desktop` run (only
|
|
2178
|
+
changed skills are rewritten — content-hash idempotency).
|
|
2168
2179
|
|
|
2169
2180
|
To remove this marker, delete this file.
|
|
2170
2181
|
"""
|
|
2171
2182
|
|
|
2183
|
+
#: Subpath under ``~/.event4u/agent-config/`` where Claude Desktop ZIP
|
|
2184
|
+
#: bundles are written. Kept separate from the per-tool USER_SCOPE_PATHS
|
|
2185
|
+
#: anchor (which is the Claude Desktop config dir) because bundles are
|
|
2186
|
+
#: package-owned, not Claude-owned, content.
|
|
2187
|
+
_CLAUDE_DESKTOP_BUNDLES_SUBPATH = "claude-desktop/bundles"
|
|
2188
|
+
|
|
2172
2189
|
|
|
2173
2190
|
def _bridge_marker(tool_id: str, scope: str) -> str:
|
|
2174
2191
|
"""Return the canonical bridge-marker path for ``(tool_id, scope)``.
|
|
@@ -2477,6 +2494,24 @@ def _load_installed_tools_module():
|
|
|
2477
2494
|
return installed_tools
|
|
2478
2495
|
|
|
2479
2496
|
|
|
2497
|
+
def _load_user_global_paths_module():
|
|
2498
|
+
"""Lazy-import ``scripts._lib.user_global_paths`` (Phase 3 migration shim)."""
|
|
2499
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
2500
|
+
if pkg_root not in sys.path:
|
|
2501
|
+
sys.path.insert(0, pkg_root)
|
|
2502
|
+
from scripts._lib import user_global_paths # noqa: WPS433 — lazy by design
|
|
2503
|
+
return user_global_paths
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
def _load_claude_desktop_bundler_module():
|
|
2507
|
+
"""Lazy-import ``scripts._lib.claude_desktop_bundler`` (Phase 4 ZIP bundler)."""
|
|
2508
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
2509
|
+
if pkg_root not in sys.path:
|
|
2510
|
+
sys.path.insert(0, pkg_root)
|
|
2511
|
+
from scripts._lib import claude_desktop_bundler # noqa: WPS433 — lazy by design
|
|
2512
|
+
return claude_desktop_bundler
|
|
2513
|
+
|
|
2514
|
+
|
|
2480
2515
|
def _sha256_of_file(path: Path) -> Optional[str]:
|
|
2481
2516
|
"""Return the hex SHA-256 of ``path`` content, or ``None`` if unreadable.
|
|
2482
2517
|
|
|
@@ -2794,29 +2829,68 @@ def _copy_dir_dereferencing_symlinks(
|
|
|
2794
2829
|
return (written, skipped, written_paths)
|
|
2795
2830
|
|
|
2796
2831
|
|
|
2832
|
+
def _claude_desktop_bundles_dir() -> Path:
|
|
2833
|
+
"""Return the canonical bundle output dir under the event4u namespace.
|
|
2834
|
+
|
|
2835
|
+
Located via :func:`user_global_paths.write_target` so the path
|
|
2836
|
+
honours the ``EVENT4U_HOME`` env override used by tests.
|
|
2837
|
+
"""
|
|
2838
|
+
paths_mod = _load_user_global_paths_module()
|
|
2839
|
+
return paths_mod.write_target(_CLAUDE_DESKTOP_BUNDLES_SUBPATH)
|
|
2840
|
+
|
|
2841
|
+
|
|
2797
2842
|
def _write_claude_desktop_marker(
|
|
2798
|
-
force: bool,
|
|
2843
|
+
force: bool,
|
|
2844
|
+
lockfile_path: Path,
|
|
2845
|
+
*,
|
|
2846
|
+
bundles_dir: Path,
|
|
2847
|
+
bundle_count: int,
|
|
2799
2848
|
) -> tuple[int, int, list[Path]]:
|
|
2800
2849
|
"""Write the Claude Desktop user-scope marker file.
|
|
2801
2850
|
|
|
2802
2851
|
Returns ``(written, skipped, written_paths)`` for symmetry with the
|
|
2803
|
-
tree copier (P1.4). The marker
|
|
2804
|
-
|
|
2852
|
+
tree copier (P1.4). The marker points users at ``bundles_dir`` for
|
|
2853
|
+
the Customize → Skills import flow (Phase 4). Existing markers are
|
|
2854
|
+
overwritten unconditionally because the bundle count is part of the
|
|
2855
|
+
body and we want it to stay current.
|
|
2805
2856
|
"""
|
|
2806
2857
|
anchor = Path(USER_SCOPE_PATHS["claude-desktop"]).expanduser()
|
|
2807
2858
|
target = anchor / "agent-config.md"
|
|
2808
|
-
decision = _resolve_file_conflict(target, force_hint=force)
|
|
2809
|
-
if decision == "skip":
|
|
2810
|
-
return (0, 1, [])
|
|
2811
2859
|
anchor.mkdir(parents=True, exist_ok=True)
|
|
2812
2860
|
body = _CLAUDE_DESKTOP_MARKER_TEMPLATE.format(
|
|
2813
2861
|
lockfile=str(lockfile_path),
|
|
2814
2862
|
anchor=str(anchor),
|
|
2863
|
+
bundles_dir=str(bundles_dir),
|
|
2864
|
+
bundle_count=bundle_count,
|
|
2815
2865
|
)
|
|
2816
2866
|
target.write_text(body, encoding="utf-8")
|
|
2817
2867
|
return (1, 0, [target])
|
|
2818
2868
|
|
|
2819
2869
|
|
|
2870
|
+
def _deploy_claude_desktop(
|
|
2871
|
+
force: bool, package_root: Path, lockfile_path: Path,
|
|
2872
|
+
) -> tuple[int, int, str, list[Path]]:
|
|
2873
|
+
"""Build skill ZIP bundles + write the marker for ``claude-desktop``.
|
|
2874
|
+
|
|
2875
|
+
Phase 4 of road-to-event4u-namespace-and-claude-desktop. Bundles
|
|
2876
|
+
land in ``~/.event4u/agent-config/claude-desktop/bundles/``; the
|
|
2877
|
+
marker file in the Claude Desktop config dir points at them with
|
|
2878
|
+
Customize → Skills import instructions.
|
|
2879
|
+
|
|
2880
|
+
Returns ``(bundle_count, 0, "deployed", [bundles_dir, marker])``.
|
|
2881
|
+
The ``deployed`` status replaces the v2.3 ``marker``-only status.
|
|
2882
|
+
"""
|
|
2883
|
+
bundler = _load_claude_desktop_bundler_module()
|
|
2884
|
+
bundles_dir = _claude_desktop_bundles_dir()
|
|
2885
|
+
bundler.build_skill_bundles(package_root, bundles_dir, force=force)
|
|
2886
|
+
# Count total existing ZIPs (idempotent runs may not rewrite any).
|
|
2887
|
+
bundle_count = sum(1 for _ in bundles_dir.glob("*.zip")) if bundles_dir.is_dir() else 0
|
|
2888
|
+
_, _, marker_paths = _write_claude_desktop_marker(
|
|
2889
|
+
force, lockfile_path, bundles_dir=bundles_dir, bundle_count=bundle_count,
|
|
2890
|
+
)
|
|
2891
|
+
return (bundle_count, 0, "deployed", [bundles_dir, *marker_paths])
|
|
2892
|
+
|
|
2893
|
+
|
|
2820
2894
|
def _deploy_global_content(
|
|
2821
2895
|
tools: set[str],
|
|
2822
2896
|
force: bool,
|
|
@@ -2827,20 +2901,21 @@ def _deploy_global_content(
|
|
|
2827
2901
|
|
|
2828
2902
|
For each tool in ``tools`` that has a ``GLOBAL_DEPLOY_SOURCES`` entry,
|
|
2829
2903
|
copies the listed package subtrees into ``USER_SCOPE_PATHS[tool_id]``
|
|
2830
|
-
(expanded). For ``claude-desktop``
|
|
2831
|
-
|
|
2832
|
-
|
|
2904
|
+
(expanded). For ``claude-desktop`` builds per-skill ZIP bundles under
|
|
2905
|
+
``~/.event4u/agent-config/claude-desktop/bundles/`` and writes the
|
|
2906
|
+
marker file pointing at them (Phase 4). For tools without a deployment
|
|
2907
|
+
plan (e.g. ``copilot``), records a ``hint`` status so the caller can
|
|
2908
|
+
print an actionable next step.
|
|
2833
2909
|
|
|
2834
2910
|
Returns ``{tool_id: (written, skipped, status, written_paths)}``
|
|
2835
|
-
where ``status`` is one of ``deployed``, ``
|
|
2836
|
-
|
|
2837
|
-
|
|
2911
|
+
where ``status`` is one of ``deployed``, ``hint``, ``unsupported``
|
|
2912
|
+
and ``written_paths`` is the absolute path list of every file the
|
|
2913
|
+
deploy actually wrote (P1.4).
|
|
2838
2914
|
"""
|
|
2839
2915
|
results: dict[str, tuple[int, int, str, list[Path]]] = {}
|
|
2840
2916
|
for tool_id in sorted(tools):
|
|
2841
2917
|
if tool_id == "claude-desktop":
|
|
2842
|
-
|
|
2843
|
-
results[tool_id] = (w, s, "marker", paths)
|
|
2918
|
+
results[tool_id] = _deploy_claude_desktop(force, package_root, lockfile_path)
|
|
2844
2919
|
continue
|
|
2845
2920
|
plan = GLOBAL_DEPLOY_SOURCES.get(tool_id)
|
|
2846
2921
|
if plan is None:
|
|
@@ -2879,8 +2954,9 @@ def install_global(
|
|
|
2879
2954
|
) -> int:
|
|
2880
2955
|
"""User-scope install path (ADR-007 + Phase 1.6 lockfile lifecycle).
|
|
2881
2956
|
|
|
2882
|
-
Reads ``~/.
|
|
2883
|
-
|
|
2957
|
+
Reads ``~/.event4u/agent-config/installed.lock`` first (with a read
|
|
2958
|
+
fallback to the legacy ``~/.config/agent-config/installed.lock``). A
|
|
2959
|
+
recorded version that does not match the running package version refuses the
|
|
2884
2960
|
install with a remediation hint unless ``--force`` is passed. On
|
|
2885
2961
|
success the lockfile is rewritten atomically with the current
|
|
2886
2962
|
package version + the union of previously-recorded and now-installed
|
|
@@ -2894,7 +2970,22 @@ def install_global(
|
|
|
2894
2970
|
presence of ``.agent-settings.yml``), the project-scope manifest at
|
|
2895
2971
|
``agents/installed-tools.lock`` is also refreshed with ``scope=global``
|
|
2896
2972
|
entries per ADR-008 Phase 3.2.
|
|
2973
|
+
|
|
2974
|
+
Phase 3 namespace migration: before any lockfile read, the legacy
|
|
2975
|
+
``~/.config/agent-config/`` tree (pre-2.4 installs) is migrated into
|
|
2976
|
+
``~/.event4u/agent-config/`` so subsequent reads land on the canonical
|
|
2977
|
+
path. The migration is idempotent and leaves a ``MIGRATED.md``
|
|
2978
|
+
breadcrumb behind; the legacy tree is never auto-deleted.
|
|
2897
2979
|
"""
|
|
2980
|
+
paths_mod = _load_user_global_paths_module()
|
|
2981
|
+
migrated = paths_mod.migrate_legacy_namespace()
|
|
2982
|
+
if migrated and not QUIET:
|
|
2983
|
+
info(
|
|
2984
|
+
"🔁 Migrated user-global config to "
|
|
2985
|
+
f"{paths_mod.event4u_root()} (legacy "
|
|
2986
|
+
f"{paths_mod.legacy_xdg_root()} preserved as fallback)"
|
|
2987
|
+
)
|
|
2988
|
+
|
|
2898
2989
|
lock_mod = _load_installed_lock_module()
|
|
2899
2990
|
installed_version = lock_mod.current_package_version()
|
|
2900
2991
|
lock_path = lock_mod.lockfile_path()
|
|
@@ -2945,7 +3036,10 @@ def install_global(
|
|
|
2945
3036
|
for tool_id in sorted(deploy_results):
|
|
2946
3037
|
w, s, status, _ = deploy_results[tool_id]
|
|
2947
3038
|
anchor = USER_SCOPE_PATHS.get(tool_id, "")
|
|
2948
|
-
if status == "deployed":
|
|
3039
|
+
if status == "deployed" and tool_id == "claude-desktop":
|
|
3040
|
+
bundles_dir = _claude_desktop_bundles_dir()
|
|
3041
|
+
print(f" {tool_id:<15} → {bundles_dir} ({w} bundles)")
|
|
3042
|
+
elif status == "deployed":
|
|
2949
3043
|
print(f" {tool_id:<15} → {anchor} ({w} files, {s} skipped)")
|
|
2950
3044
|
elif status == "marker":
|
|
2951
3045
|
print(f" {tool_id:<15} → {anchor}agent-config.md ({'written' if w else 'skipped'})")
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Reads the key with `read -s` so it never echoes to the terminal and
|
|
5
5
|
# never lands in shell history or scrollback. Writes atomically to
|
|
6
|
-
# ~/.
|
|
6
|
+
# ~/.event4u/agent-config/anthropic.key with mode 0600. The legacy
|
|
7
|
+
# ~/.config/agent-config/anthropic.key is read as a fallback by the
|
|
8
|
+
# loaders so pre-2.4 installs keep working until the namespace shim runs.
|
|
7
9
|
#
|
|
8
10
|
# Contract — companion to scripts/skill_trigger_eval.py:
|
|
9
|
-
# - File path: $HOME/.
|
|
11
|
+
# - File path: $HOME/.event4u/agent-config/anthropic.key
|
|
10
12
|
# - File mode: 0600 (owner read/write only)
|
|
11
13
|
# - Key format: must start with `sk-ant-`
|
|
12
14
|
# - No --force, no --yes, no env-var bypass. Piped stdin is rejected.
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
|
|
17
19
|
set -euo pipefail
|
|
18
20
|
|
|
19
|
-
TARGET_DIR="${HOME}/.
|
|
21
|
+
TARGET_DIR="${HOME}/.event4u/agent-config"
|
|
20
22
|
TARGET_FILE="${TARGET_DIR}/anthropic.key"
|
|
21
23
|
|
|
22
24
|
# ── controlling-terminal requirement ─────────────────────────────────────
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
#
|
|
4
4
|
# Reads the key with `read -s` so it never echoes to the terminal and
|
|
5
5
|
# never lands in shell history or scrollback. Writes atomically to
|
|
6
|
-
# ~/.
|
|
6
|
+
# ~/.event4u/agent-config/openai.key with mode 0600. The legacy
|
|
7
|
+
# ~/.config/agent-config/openai.key is read as a fallback by the loaders
|
|
8
|
+
# so pre-2.4 installs keep working until the namespace shim runs.
|
|
7
9
|
#
|
|
8
10
|
# Contract — companion to scripts/ai_council/clients.py:
|
|
9
|
-
# - File path: $HOME/.
|
|
11
|
+
# - File path: $HOME/.event4u/agent-config/openai.key
|
|
10
12
|
# - File mode: 0600 (owner read/write only)
|
|
11
13
|
# - Key format: must start with `sk-`
|
|
12
14
|
# - No --force, no --yes, no env-var bypass. Piped stdin is rejected.
|
|
@@ -16,7 +18,7 @@
|
|
|
16
18
|
|
|
17
19
|
set -euo pipefail
|
|
18
20
|
|
|
19
|
-
TARGET_DIR="${HOME}/.
|
|
21
|
+
TARGET_DIR="${HOME}/.event4u/agent-config"
|
|
20
22
|
TARGET_FILE="${TARGET_DIR}/openai.key"
|
|
21
23
|
|
|
22
24
|
# ── controlling-terminal requirement ─────────────────────────────────────
|