@event4u/agent-config 2.3.0 → 2.4.1
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 +39 -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 +17 -7
- package/scripts/_cli/cmd_update.py +11 -7
- 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 +56 -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 +149 -49
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- package/scripts/skill_trigger_eval.py +13 -2
package/package.json
CHANGED
|
@@ -302,7 +302,7 @@ def _parse(argv: list[str]) -> argparse.Namespace:
|
|
|
302
302
|
),
|
|
303
303
|
)
|
|
304
304
|
parser.add_argument("--global", dest="global_mode", action="store_true",
|
|
305
|
-
help="operate on user-scope lockfile (~/.config/agent-config/installed.lock)")
|
|
305
|
+
help="operate on user-scope lockfile (~/.event4u/agent-config/installed.lock; legacy ~/.config/agent-config/installed.lock read as fallback)")
|
|
306
306
|
parser.add_argument("--tools", default=None,
|
|
307
307
|
help="comma-separated tool IDs to uninstall (default: all in lockfile)")
|
|
308
308
|
parser.add_argument("--project", default=None, help="project root (default: cwd)")
|
|
@@ -422,6 +422,7 @@ def _uninstall_project(opts: argparse.Namespace) -> int:
|
|
|
422
422
|
|
|
423
423
|
def _uninstall_global(opts: argparse.Namespace) -> int:
|
|
424
424
|
lock_path = installed_lock.lockfile_path()
|
|
425
|
+
write_path = installed_lock.lockfile_write_path()
|
|
425
426
|
lock = installed_lock.read_lockfile(lock_path)
|
|
426
427
|
if lock is None and not opts.force:
|
|
427
428
|
print(f"❌ no global lockfile at {lock_path}", file=sys.stderr)
|
|
@@ -442,15 +443,24 @@ def _uninstall_global(opts: argparse.Namespace) -> int:
|
|
|
442
443
|
removed_names.append(tool)
|
|
443
444
|
if lock is not None and not opts.dry_run:
|
|
444
445
|
remaining = [t for t in lock.get("tools", []) if t not in tools]
|
|
446
|
+
version = lock.get("agent_config_version", "")
|
|
445
447
|
if remaining:
|
|
446
|
-
installed_lock.write_lockfile(remaining,
|
|
448
|
+
installed_lock.write_lockfile(version, remaining, path=write_path)
|
|
449
|
+
# Drop the legacy file if it differs from the canonical write
|
|
450
|
+
# target so the namespace migration completes on uninstall.
|
|
451
|
+
if lock_path != write_path:
|
|
452
|
+
try:
|
|
453
|
+
lock_path.unlink()
|
|
454
|
+
except OSError:
|
|
455
|
+
pass
|
|
447
456
|
print(f"✅ lockfile updated ({len(tools)} entries removed, {len(remaining)} kept)")
|
|
448
457
|
else:
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
458
|
+
for target in {lock_path, write_path}:
|
|
459
|
+
try:
|
|
460
|
+
target.unlink()
|
|
461
|
+
except OSError:
|
|
462
|
+
pass
|
|
463
|
+
print(f"✅ lockfile deleted ({write_path})")
|
|
454
464
|
return 0
|
|
455
465
|
|
|
456
466
|
|
|
@@ -233,24 +233,28 @@ def main(
|
|
|
233
233
|
|
|
234
234
|
|
|
235
235
|
def _refresh_global_lockfile(version: str, *, out=sys.stdout) -> None:
|
|
236
|
-
"""Update
|
|
236
|
+
"""Update the global ``installed.lock`` if it exists.
|
|
237
|
+
|
|
238
|
+
Resolution prefers ``~/.event4u/agent-config/installed.lock`` and
|
|
239
|
+
falls back to the legacy ``~/.config/agent-config/installed.lock``.
|
|
237
240
|
|
|
238
241
|
Phase 1.6 — the lockfile is only present when the user has run a
|
|
239
242
|
global install; we never create one here, but we keep it in lockstep
|
|
240
243
|
when ``update`` flips the pin. Atomic write goes through
|
|
241
244
|
``installed_lock.write_lockfile``.
|
|
242
245
|
"""
|
|
243
|
-
|
|
244
|
-
|
|
246
|
+
read_path = installed_lock.lockfile_path()
|
|
247
|
+
write_path = installed_lock.lockfile_write_path()
|
|
248
|
+
existing = installed_lock.read_lockfile(path=read_path)
|
|
245
249
|
if existing is None:
|
|
246
250
|
return
|
|
247
251
|
recorded = existing.get("agent_config_version")
|
|
248
252
|
tools = list(existing.get("tools", []))
|
|
249
|
-
if recorded == version:
|
|
250
|
-
print(f"ℹ️ {
|
|
253
|
+
if recorded == version and read_path == write_path:
|
|
254
|
+
print(f"ℹ️ {write_path} already records {version}.", file=out)
|
|
251
255
|
return
|
|
252
|
-
installed_lock.write_lockfile(version, tools, path=
|
|
253
|
-
print(f"✅ Refreshed global lockfile at {
|
|
256
|
+
installed_lock.write_lockfile(version, tools, path=write_path)
|
|
257
|
+
print(f"✅ Refreshed global lockfile at {write_path}.", file=out)
|
|
254
258
|
|
|
255
259
|
|
|
256
260
|
def _detect_installed_version() -> str:
|
|
@@ -5,11 +5,18 @@ how scripts read agent settings — replaces ~15 ad-hoc loaders in P3.
|
|
|
5
5
|
|
|
6
6
|
Resolution order (deepest wins; user-global is whitelist-filtered only):
|
|
7
7
|
|
|
8
|
-
N. ``~/.
|
|
8
|
+
N. ``~/.event4u/agent-config/agent-settings.yml`` (user-global; whitelist only)
|
|
9
9
|
N-1. ``<repo-root>/.agent-settings.yml`` (project-wide; all keys)
|
|
10
10
|
N-2. ``<intermediate-dir>/.agent-settings.yml`` (subsystem-scoped; all keys)
|
|
11
11
|
1. ``<CWD>/.agent-settings.yml`` (deepest, wins; all keys)
|
|
12
12
|
|
|
13
|
+
The user-global path is resolved via the sibling
|
|
14
|
+
:mod:`work_engine._lib.user_global_paths` module (vendored from
|
|
15
|
+
``scripts/_lib/user_global_paths.py`` so the engine stays self-contained
|
|
16
|
+
when shipped into consumer projects) with a read-fallback to the legacy
|
|
17
|
+
``~/.config/agent-config/agent-settings.yml`` so pre-2.4 installs keep
|
|
18
|
+
working during the namespace migration.
|
|
19
|
+
|
|
13
20
|
``<repo-root>`` is the nearest ancestor that contains ``.git`` (directory
|
|
14
21
|
**or** file — submodule support). The walk stops there — it never drifts
|
|
15
22
|
into a parent repo or ``$HOME``. When ``cwd`` is ``None`` (default), the
|
|
@@ -37,12 +44,26 @@ import logging
|
|
|
37
44
|
from pathlib import Path
|
|
38
45
|
from typing import Any, Iterator
|
|
39
46
|
|
|
47
|
+
from . import user_global_paths
|
|
48
|
+
|
|
40
49
|
logger = logging.getLogger(__name__)
|
|
41
50
|
|
|
42
51
|
DEFAULT_PROJECT_FILE = ".agent-settings.yml"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
USER_GLOBAL_FILENAME = "agent-settings.yml"
|
|
53
|
+
|
|
54
|
+
#: Canonical write target under the new ``~/.event4u/agent-config/``
|
|
55
|
+
#: namespace. Reads route through :func:`_resolve_user_global_file` so
|
|
56
|
+
#: pre-2.4 installs are still picked up from ``~/.config/agent-config/``
|
|
57
|
+
#: until the migration shim copies them across.
|
|
58
|
+
DEFAULT_USER_GLOBAL_FILE = user_global_paths.write_target(USER_GLOBAL_FILENAME)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_user_global_file() -> Path:
|
|
62
|
+
"""Return the active user-global settings path with legacy fallback."""
|
|
63
|
+
found = user_global_paths.resolve_with_fallback(USER_GLOBAL_FILENAME)
|
|
64
|
+
if found is not None:
|
|
65
|
+
return found
|
|
66
|
+
return DEFAULT_USER_GLOBAL_FILE
|
|
46
67
|
|
|
47
68
|
#: Exact dotted paths allowed to cascade from user-global into the merged
|
|
48
69
|
#: settings. Anything not listed here is silently ignored when present in
|
|
@@ -129,7 +150,8 @@ def load_agent_settings(
|
|
|
129
150
|
|
|
130
151
|
``project_path`` defaults to ``./.agent-settings.yml`` (CWD-relative).
|
|
131
152
|
``user_global_path`` defaults to
|
|
132
|
-
``~/.
|
|
153
|
+
``~/.event4u/agent-config/agent-settings.yml`` (with a read fallback
|
|
154
|
+
to the legacy ``~/.config/agent-config/agent-settings.yml``). Both arguments accept
|
|
133
155
|
``Path`` or ``str``. Pass ``verbose=True`` to log keys present in
|
|
134
156
|
user-global that are not on the whitelist.
|
|
135
157
|
|
|
@@ -143,7 +165,7 @@ def load_agent_settings(
|
|
|
143
165
|
with pre-cascade callers.
|
|
144
166
|
"""
|
|
145
167
|
user_global_raw = _read_yaml(
|
|
146
|
-
Path(user_global_path) if user_global_path else
|
|
168
|
+
Path(user_global_path) if user_global_path else _resolve_user_global_file(),
|
|
147
169
|
) or {}
|
|
148
170
|
|
|
149
171
|
user_global_filtered, ignored = _filter_whitelist(
|
|
@@ -181,7 +203,7 @@ def iter_setting_overrides(
|
|
|
181
203
|
Never blocks, never raises on missing files.
|
|
182
204
|
"""
|
|
183
205
|
user_global_path_resolved = (
|
|
184
|
-
Path(user_global_path) if user_global_path else
|
|
206
|
+
Path(user_global_path) if user_global_path else _resolve_user_global_file()
|
|
185
207
|
)
|
|
186
208
|
user_global_raw = _read_yaml(user_global_path_resolved) or {}
|
|
187
209
|
user_global_filtered, _ = _filter_whitelist(user_global_raw, MERGEABLE_KEYS)
|
|
@@ -8,9 +8,12 @@ user-global directory (when the ``kind`` is whitelisted).
|
|
|
8
8
|
|
|
9
9
|
Resolution order (deepest wins, every layer optional):
|
|
10
10
|
|
|
11
|
-
N. ``~/.
|
|
11
|
+
N. ``~/.event4u/agent-config/agents/<kind>/<name>.md`` (user-global; weakest;
|
|
12
12
|
``kind`` must be in
|
|
13
|
-
``USER_GLOBAL_OVERLAY_KINDS
|
|
13
|
+
``USER_GLOBAL_OVERLAY_KINDS``;
|
|
14
|
+
legacy
|
|
15
|
+
``~/.config/agent-config/agents/``
|
|
16
|
+
tree read as fallback)
|
|
14
17
|
N-1. ``<repo-root>/agents/<kind>/<name>.md``
|
|
15
18
|
N-2. ``<intermediate-dir>/agents/<kind>/<name>.md`` (optional)
|
|
16
19
|
1. ``<CWD>/agents/<kind>/<name>.md`` (deepest, wins)
|
|
@@ -34,6 +37,7 @@ from __future__ import annotations
|
|
|
34
37
|
import logging
|
|
35
38
|
from pathlib import Path
|
|
36
39
|
|
|
40
|
+
from scripts._lib import user_global_paths
|
|
37
41
|
from scripts._lib.agent_settings import find_project_root
|
|
38
42
|
|
|
39
43
|
logger = logging.getLogger(__name__)
|
|
@@ -50,13 +54,17 @@ CASCADE_ELIGIBLE_KINDS: frozenset[str] = frozenset({
|
|
|
50
54
|
})
|
|
51
55
|
|
|
52
56
|
#: Subset of :data:`CASCADE_ELIGIBLE_KINDS` allowed to live at the
|
|
53
|
-
#: user-global layer (``~/.
|
|
57
|
+
#: user-global layer (``~/.event4u/agent-config/agents/<kind>/``).
|
|
54
58
|
#: ``contexts/`` and ``decisions/`` are project-shaped and must not leak
|
|
55
59
|
#: across projects; only ``overrides/`` — the developer's personal
|
|
56
60
|
#: layer — is whitelisted.
|
|
57
61
|
USER_GLOBAL_OVERLAY_KINDS: frozenset[str] = frozenset({"overrides"})
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
#: Canonical write target under the new vendor namespace. The probe in
|
|
64
|
+
#: :func:`resolve_overlay` adds the legacy ``~/.config/agent-config/agents/``
|
|
65
|
+
#: tree as a read-only fallback for pre-2.4 installs.
|
|
66
|
+
USER_GLOBAL_AGENTS_DIR = user_global_paths.write_target("agents")
|
|
67
|
+
_LEGACY_USER_GLOBAL_AGENTS_DIR = user_global_paths.legacy_xdg_root() / "agents"
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
|
|
@@ -83,6 +91,9 @@ def resolve_overlay(name: str, kind: str, cwd: Path) -> Path | None:
|
|
|
83
91
|
candidates: list[Path] = []
|
|
84
92
|
|
|
85
93
|
if kind in USER_GLOBAL_OVERLAY_KINDS:
|
|
94
|
+
# Legacy first, new last — deepest wins, so the new namespace
|
|
95
|
+
# overrides the legacy path when both happen to exist mid-migration.
|
|
96
|
+
candidates.append(_LEGACY_USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
|
|
86
97
|
candidates.append(USER_GLOBAL_AGENTS_DIR / kind / f"{name}.md")
|
|
87
98
|
|
|
88
99
|
root = find_project_root(cwd)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Claude Desktop skill ZIP bundler (Phase 4 of event4u-namespace roadmap).
|
|
2
|
+
|
|
3
|
+
Claude Desktop has no filesystem convention for skills; the Customize →
|
|
4
|
+
Skills UI accepts a ZIP per skill via the Upload button. This module
|
|
5
|
+
walks ``<package_root>/.agent-src/skills/*`` and produces one
|
|
6
|
+
``<skill-name>.zip`` per directory into ``dest_dir``.
|
|
7
|
+
|
|
8
|
+
Contract:
|
|
9
|
+
|
|
10
|
+
- Each ZIP contains ``SKILL.md`` plus every sibling file under the same
|
|
11
|
+
directory (recursive). Symlinks are dereferenced so the ZIP is
|
|
12
|
+
self-contained.
|
|
13
|
+
- Exclusions: ``.git*``, ``__pycache__``, ``*.pyc`` — matched on the
|
|
14
|
+
basename of any path component.
|
|
15
|
+
- A skill folder without a ``SKILL.md`` is skipped (defensive: avoids
|
|
16
|
+
shipping Claude-Code orchestrator stubs that don't follow the
|
|
17
|
+
Anthropic skill schema).
|
|
18
|
+
- Writes are atomic via tempfile → ``os.replace``.
|
|
19
|
+
- Idempotent: each ZIP gets a sibling ``<skill-name>.sha256`` recording
|
|
20
|
+
the manifest digest. If the recomputed digest matches the recorded
|
|
21
|
+
one, the existing ZIP is left untouched (unless ``force=True``).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import os
|
|
27
|
+
import tempfile
|
|
28
|
+
import zipfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Iterable, Optional
|
|
31
|
+
|
|
32
|
+
#: Filenames or path components that are never included in a bundle.
|
|
33
|
+
_EXCLUDED_BASENAMES = frozenset({"__pycache__", ".DS_Store"})
|
|
34
|
+
_EXCLUDED_PREFIXES = (".git",)
|
|
35
|
+
_EXCLUDED_SUFFIXES = (".pyc", ".pyo")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_excluded(rel_parts: tuple[str, ...]) -> bool:
|
|
39
|
+
"""Return True if any component matches the exclusion lists."""
|
|
40
|
+
for part in rel_parts:
|
|
41
|
+
if part in _EXCLUDED_BASENAMES:
|
|
42
|
+
return True
|
|
43
|
+
if part.startswith(_EXCLUDED_PREFIXES):
|
|
44
|
+
return True
|
|
45
|
+
if part.endswith(_EXCLUDED_SUFFIXES):
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _walk_skill_files(skill_dir: Path) -> list[tuple[Path, tuple[str, ...]]]:
|
|
51
|
+
"""Return ``[(abs_path, rel_parts), ...]`` for every file in the skill.
|
|
52
|
+
|
|
53
|
+
Symlinks are followed (``os.walk(..., followlinks=True)``) so a
|
|
54
|
+
bundle from a symlinked entry under ``.agent-src/skills/`` contains
|
|
55
|
+
the actual target content, not a dangling symlink.
|
|
56
|
+
"""
|
|
57
|
+
out: list[tuple[Path, tuple[str, ...]]] = []
|
|
58
|
+
resolved = skill_dir.resolve()
|
|
59
|
+
for root, dirs, files in os.walk(resolved, followlinks=True):
|
|
60
|
+
root_path = Path(root)
|
|
61
|
+
rel_root = root_path.relative_to(resolved)
|
|
62
|
+
# Prune excluded dirs in-place so os.walk skips them.
|
|
63
|
+
dirs[:] = [d for d in dirs if not _is_excluded((d,))]
|
|
64
|
+
for fname in files:
|
|
65
|
+
rel_parts = (*rel_root.parts, fname) if rel_root.parts else (fname,)
|
|
66
|
+
if _is_excluded(rel_parts):
|
|
67
|
+
continue
|
|
68
|
+
out.append((root_path / fname, rel_parts))
|
|
69
|
+
out.sort(key=lambda item: item[1])
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _manifest_digest(files: Iterable[tuple[Path, tuple[str, ...]]]) -> str:
|
|
74
|
+
"""Hash sorted (rel_path, content_sha256) pairs into one digest.
|
|
75
|
+
|
|
76
|
+
Stable across runs as long as the input set + bytes are stable. Used
|
|
77
|
+
as the idempotency token written to ``<skill>.sha256``.
|
|
78
|
+
"""
|
|
79
|
+
h = hashlib.sha256()
|
|
80
|
+
for abs_path, rel_parts in files:
|
|
81
|
+
rel = "/".join(rel_parts)
|
|
82
|
+
h.update(rel.encode("utf-8"))
|
|
83
|
+
h.update(b"\x00")
|
|
84
|
+
h.update(hashlib.sha256(abs_path.read_bytes()).digest())
|
|
85
|
+
h.update(b"\x00")
|
|
86
|
+
return h.hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _atomic_write_zip(
|
|
90
|
+
zip_path: Path, files: list[tuple[Path, tuple[str, ...]]]
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Write ``files`` into ``zip_path`` atomically (temp + rename)."""
|
|
93
|
+
zip_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
95
|
+
prefix=f".{zip_path.stem}.", suffix=".zip.tmp", dir=str(zip_path.parent),
|
|
96
|
+
)
|
|
97
|
+
os.close(fd)
|
|
98
|
+
tmp_path = Path(tmp_name)
|
|
99
|
+
try:
|
|
100
|
+
with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
101
|
+
for abs_path, rel_parts in files:
|
|
102
|
+
zf.write(abs_path, arcname="/".join(rel_parts))
|
|
103
|
+
os.replace(tmp_path, zip_path)
|
|
104
|
+
finally:
|
|
105
|
+
if tmp_path.exists():
|
|
106
|
+
tmp_path.unlink()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_skill_bundles(
|
|
110
|
+
package_root: Path,
|
|
111
|
+
dest_dir: Path,
|
|
112
|
+
force: bool = False,
|
|
113
|
+
curation: Optional[list[str]] = None,
|
|
114
|
+
) -> list[Path]:
|
|
115
|
+
"""Build per-skill ZIPs under ``dest_dir``.
|
|
116
|
+
|
|
117
|
+
Returns the list of ZIP paths that were (re-)written this call. ZIPs
|
|
118
|
+
skipped because their content digest matched the existing sidecar
|
|
119
|
+
are not in the returned list (but remain on disk).
|
|
120
|
+
|
|
121
|
+
``curation`` optionally restricts the build to the given skill
|
|
122
|
+
names; ``None`` bundles every skill folder containing ``SKILL.md``.
|
|
123
|
+
"""
|
|
124
|
+
skills_root = package_root / ".agent-src" / "skills"
|
|
125
|
+
if not skills_root.is_dir():
|
|
126
|
+
return []
|
|
127
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
written: list[Path] = []
|
|
129
|
+
for entry in sorted(skills_root.iterdir()):
|
|
130
|
+
if not (entry.is_dir() or entry.is_symlink()):
|
|
131
|
+
continue
|
|
132
|
+
skill_name = entry.name
|
|
133
|
+
if curation is not None and skill_name not in curation:
|
|
134
|
+
continue
|
|
135
|
+
skill_md = entry / "SKILL.md"
|
|
136
|
+
if not skill_md.exists():
|
|
137
|
+
continue
|
|
138
|
+
files = _walk_skill_files(entry)
|
|
139
|
+
if not files:
|
|
140
|
+
continue
|
|
141
|
+
digest = _manifest_digest(files)
|
|
142
|
+
zip_path = dest_dir / f"{skill_name}.zip"
|
|
143
|
+
digest_path = dest_dir / f"{skill_name}.sha256"
|
|
144
|
+
recorded = digest_path.read_text(encoding="utf-8").strip() if digest_path.exists() else ""
|
|
145
|
+
if not force and recorded == digest and zip_path.exists():
|
|
146
|
+
continue
|
|
147
|
+
_atomic_write_zip(zip_path, files)
|
|
148
|
+
digest_path.write_text(digest + "\n", encoding="utf-8")
|
|
149
|
+
written.append(zip_path)
|
|
150
|
+
return written
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Global-install lockfile at ``~/.
|
|
1
|
+
"""Global-install lockfile at ``~/.event4u/agent-config/installed.lock``.
|
|
2
2
|
|
|
3
3
|
Phase 1.6 of road-to-global-first-install (ADR-007 D5). Records the
|
|
4
4
|
package version that performed the most recent user-scope install plus
|
|
@@ -10,6 +10,12 @@ flip in ``.agent-settings.yml``.
|
|
|
10
10
|
The schema is intentionally minimal YAML so the module can read and
|
|
11
11
|
write without depending on ``pyyaml``. Atomic writes go through
|
|
12
12
|
``tempfile + os.replace`` per ADR-007 risk-mitigation row.
|
|
13
|
+
|
|
14
|
+
Path resolution is delegated to :mod:`scripts._lib.user_global_paths`
|
|
15
|
+
(Phase 1 of road-to-event4u-namespace-and-claude-desktop.md): writes
|
|
16
|
+
land at ``~/.event4u/agent-config/installed.lock``; reads fall back to
|
|
17
|
+
the legacy ``~/.config/agent-config/installed.lock`` if the new path
|
|
18
|
+
is missing, so pre-2.4 installs keep working during the transition.
|
|
13
19
|
"""
|
|
14
20
|
from __future__ import annotations
|
|
15
21
|
|
|
@@ -21,10 +27,22 @@ from datetime import datetime, timezone
|
|
|
21
27
|
from pathlib import Path
|
|
22
28
|
from typing import Optional
|
|
23
29
|
|
|
30
|
+
from scripts._lib import user_global_paths
|
|
31
|
+
|
|
24
32
|
LOCKFILE_ENV = "AGENT_CONFIG_INSTALLED_LOCK"
|
|
25
|
-
DEFAULT_LOCKFILE = Path.home() / ".config" / "agent-config" / "installed.lock"
|
|
26
33
|
SCHEMA_VERSION = 1
|
|
27
34
|
|
|
35
|
+
|
|
36
|
+
def _default_lockfile() -> Path:
|
|
37
|
+
"""Canonical write target for the lockfile (new namespace)."""
|
|
38
|
+
return user_global_paths.write_target("installed.lock")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Module-level constant retained for back-compat with importers that read
|
|
42
|
+
# ``installed_lock.DEFAULT_LOCKFILE`` directly. Derived from the helper so
|
|
43
|
+
# the path tracks any future override of ``event4u_root()``.
|
|
44
|
+
DEFAULT_LOCKFILE = _default_lockfile()
|
|
45
|
+
|
|
28
46
|
_VERSION_RE = re.compile(r'^\s*agent_config_version\s*:\s*"?([^"\s]+)"?\s*$')
|
|
29
47
|
_SCHEMA_RE = re.compile(r"^\s*schema_version\s*:\s*(\d+)\s*$")
|
|
30
48
|
_INSTALLED_AT_RE = re.compile(r'^\s*installed_at\s*:\s*"?([^"\s]+)"?\s*$')
|
|
@@ -32,12 +50,46 @@ _TOOL_RE = re.compile(r"^\s*-\s*([A-Za-z0-9_\-.]+)\s*$")
|
|
|
32
50
|
|
|
33
51
|
|
|
34
52
|
def lockfile_path(env: Optional[dict] = None) -> Path:
|
|
35
|
-
"""Return the active lockfile path
|
|
53
|
+
"""Return the active lockfile path for **reads**, honoring overrides.
|
|
54
|
+
|
|
55
|
+
Resolution order:
|
|
56
|
+
|
|
57
|
+
1. ``$AGENT_CONFIG_INSTALLED_LOCK`` — explicit full-path override.
|
|
58
|
+
2. ``~/.event4u/agent-config/installed.lock`` if it exists on disk.
|
|
59
|
+
3. ``~/.config/agent-config/installed.lock`` (legacy fallback, read-only).
|
|
60
|
+
4. Canonical write target under the new namespace (Step 2 fallthrough).
|
|
61
|
+
|
|
62
|
+
Readers benefit from (3) so pre-2.4 installs keep working while the
|
|
63
|
+
migration shim has not yet run. Writers must use
|
|
64
|
+
:func:`lockfile_write_path` so a stale legacy file does not anchor
|
|
65
|
+
subsequent writes to the deprecated location.
|
|
66
|
+
"""
|
|
67
|
+
env = env if env is not None else os.environ
|
|
68
|
+
override = env.get(LOCKFILE_ENV)
|
|
69
|
+
if override:
|
|
70
|
+
return Path(override).expanduser()
|
|
71
|
+
resolved = user_global_paths.resolve_with_fallback("installed.lock", env=env)
|
|
72
|
+
if resolved is not None:
|
|
73
|
+
return resolved
|
|
74
|
+
return user_global_paths.write_target("installed.lock", env=env)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def lockfile_write_path(env: Optional[dict] = None) -> Path:
|
|
78
|
+
"""Return the canonical write target for the lockfile.
|
|
79
|
+
|
|
80
|
+
Unlike :func:`lockfile_path`, this never falls back to the legacy
|
|
81
|
+
``~/.config/agent-config/`` location. Honors the
|
|
82
|
+
``$AGENT_CONFIG_INSTALLED_LOCK`` override for tests, otherwise pins
|
|
83
|
+
to ``~/.event4u/agent-config/installed.lock``. Callers in
|
|
84
|
+
``init``, ``update``, and ``uninstall`` use this so writes always
|
|
85
|
+
land in the new namespace regardless of whether a stale legacy
|
|
86
|
+
lockfile is still present.
|
|
87
|
+
"""
|
|
36
88
|
env = env if env is not None else os.environ
|
|
37
89
|
override = env.get(LOCKFILE_ENV)
|
|
38
90
|
if override:
|
|
39
91
|
return Path(override).expanduser()
|
|
40
|
-
return
|
|
92
|
+
return user_global_paths.write_target("installed.lock", env=env)
|
|
41
93
|
|
|
42
94
|
|
|
43
95
|
def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
|
|
@@ -4,7 +4,7 @@ Phase 3 of road-to-global-first-install (ADR-008). Committed
|
|
|
4
4
|
bill-of-materials for AI tooling a project depends on. Sibling to the
|
|
5
5
|
global lockfile (``installed_lock.py``) but architecturally distinct:
|
|
6
6
|
|
|
7
|
-
- ``installed_lock.py`` lives in ``~/.
|
|
7
|
+
- ``installed_lock.py`` lives in ``~/.event4u/agent-config/`` and tracks
|
|
8
8
|
the user-scope environment (a single ``agent_config_version`` and a
|
|
9
9
|
flat ``tools[]`` list).
|
|
10
10
|
- ``installed_tools.py`` lives in ``agents/`` and tracks **per-project**
|
|
@@ -10,7 +10,10 @@ Design constraints (see roadmap P2):
|
|
|
10
10
|
|
|
11
11
|
- Stdlib only (no new deps); the package's Python floor is stdlib-only.
|
|
12
12
|
- 1 s hard timeout on the registry call; network failure is silent.
|
|
13
|
-
- 24 h cadence gated by ``~/.
|
|
13
|
+
- 24 h cadence gated by ``~/.event4u/agent-config/update-check.json``
|
|
14
|
+
(legacy ``~/.config/agent-config/update-check.json`` is read once as
|
|
15
|
+
a fallback so the cadence is not reset on the first run after the
|
|
16
|
+
Phase-1 namespace migration).
|
|
14
17
|
- Suppress in CI, on non-TTY stdout, when ``AGENT_CONFIG_NO_UPDATE_CHECK=1``,
|
|
15
18
|
or when ``update_check.enabled: false`` in settings.
|
|
16
19
|
- State file mode is ``0600``.
|
|
@@ -30,12 +33,29 @@ from datetime import datetime, timedelta, timezone
|
|
|
30
33
|
from pathlib import Path
|
|
31
34
|
from typing import Optional
|
|
32
35
|
|
|
36
|
+
from scripts._lib import user_global_paths
|
|
37
|
+
|
|
33
38
|
PACKAGE_NAME = "@event4u/agent-config"
|
|
34
39
|
NPM_REGISTRY_URL = f"https://registry.npmjs.org/{PACKAGE_NAME}/latest"
|
|
35
40
|
FETCH_TIMEOUT_S = 1.0
|
|
36
41
|
CHECK_WINDOW = timedelta(hours=24)
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
STATE_FILENAME = "update-check.json"
|
|
44
|
+
|
|
45
|
+
#: Canonical write target. Reads are routed via
|
|
46
|
+
#: :func:`_resolve_state_path` with a read-fallback to the legacy
|
|
47
|
+
#: ``~/.config/agent-config/update-check.json`` so a fresh install
|
|
48
|
+
#: under the new namespace does not lose the 24 h cadence window
|
|
49
|
+
#: established by a pre-2.4 install.
|
|
50
|
+
DEFAULT_STATE_PATH = user_global_paths.write_target(STATE_FILENAME)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_state_path() -> Path:
|
|
54
|
+
"""Return the active state path, preferring the new namespace."""
|
|
55
|
+
found = user_global_paths.resolve_with_fallback(STATE_FILENAME)
|
|
56
|
+
if found is not None:
|
|
57
|
+
return found
|
|
58
|
+
return DEFAULT_STATE_PATH
|
|
39
59
|
|
|
40
60
|
|
|
41
61
|
def _now_utc() -> datetime:
|
|
@@ -159,8 +179,12 @@ def check_for_update(
|
|
|
159
179
|
return None
|
|
160
180
|
|
|
161
181
|
now = now or _now_utc()
|
|
162
|
-
|
|
163
|
-
|
|
182
|
+
# When the caller does not pin a state path, route through the
|
|
183
|
+
# fallback resolver so a pre-2.4 install's cadence file is still
|
|
184
|
+
# consulted before we decide to re-check npm.
|
|
185
|
+
read_path = state_path or _resolve_state_path()
|
|
186
|
+
write_path = state_path or DEFAULT_STATE_PATH
|
|
187
|
+
state = _read_state(read_path)
|
|
164
188
|
if not _should_check(state, now):
|
|
165
189
|
latest = state.get("last_seen_version")
|
|
166
190
|
if isinstance(latest, str) and _is_newer(latest, installed_version):
|
|
@@ -174,7 +198,7 @@ def check_for_update(
|
|
|
174
198
|
"installed_version": installed_version,
|
|
175
199
|
}
|
|
176
200
|
try:
|
|
177
|
-
_write_state(
|
|
201
|
+
_write_state(write_path, payload)
|
|
178
202
|
except OSError:
|
|
179
203
|
pass
|
|
180
204
|
|