@event4u/agent-config 2.2.2 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent-src/commands/onboard.md +14 -9
- package/.agent-src/rules/external-reference-deep-dive.md +69 -0
- package/.agent-src/skills/ai-council/SKILL.md +5 -3
- package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
- package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
- package/.agent-src/templates/copilot-instructions.md +7 -0
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
- package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
- package/.claude-plugin/marketplace.json +27 -1
- package/CHANGELOG.md +79 -0
- package/README.md +1 -8
- package/config/agent-settings.template.yml +5 -3
- package/docs/architecture.md +1 -1
- package/docs/catalog.md +5 -3
- package/docs/contracts/installed-tools-lockfile.md +142 -0
- package/docs/customization.md +23 -17
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
- package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/development.md +37 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
- package/docs/guidelines/agent-infra/layered-settings.md +6 -4
- package/docs/installation.md +17 -2
- package/docs/migration/v1-to-v2.md +45 -0
- package/docs/setup/per-ide/antigravity.md +63 -0
- package/docs/setup/per-ide/augment.md +77 -0
- package/docs/setup/per-ide/claude-desktop.md +107 -65
- package/docs/setup/per-ide/codebuddy.md +63 -0
- package/docs/setup/per-ide/continue.md +68 -0
- package/docs/setup/per-ide/droid.md +65 -0
- package/docs/setup/per-ide/jetbrains.md +76 -0
- package/docs/setup/per-ide/kilocode.md +66 -0
- package/docs/setup/per-ide/kiro.md +72 -0
- package/docs/setup/per-ide/opencode.md +62 -0
- package/docs/setup/per-ide/qoder.md +63 -0
- package/docs/setup/per-ide/roocode.md +68 -0
- package/docs/setup/per-ide/trae.md +63 -0
- package/docs/setup/per-ide/warp.md +63 -0
- package/docs/setup/per-ide/zed.md +73 -0
- package/package.json +1 -1
- package/scripts/_cli/cmd_doctor.py +351 -0
- package/scripts/_cli/cmd_prune.py +317 -0
- package/scripts/_cli/cmd_uninstall.py +465 -0
- package/scripts/_cli/cmd_update.py +30 -4
- package/scripts/_cli/cmd_versions.py +147 -0
- package/scripts/_lib/agent_settings.py +29 -7
- package/scripts/_lib/agents_overlay.py +15 -4
- package/scripts/_lib/claude_desktop_bundler.py +150 -0
- package/scripts/_lib/fs_atomic.py +116 -0
- package/scripts/_lib/installed_lock.py +37 -4
- package/scripts/_lib/installed_tools.py +189 -45
- package/scripts/_lib/json_pointers.py +260 -0
- package/scripts/_lib/update_check.py +29 -5
- package/scripts/_lib/user_global_paths.py +249 -0
- package/scripts/agent-config +69 -0
- package/scripts/ai_council/__init__.py +4 -3
- package/scripts/ai_council/budget_guard.py +34 -4
- package/scripts/ai_council/bundler.py +2 -0
- package/scripts/ai_council/clients.py +28 -7
- package/scripts/compress.py +78 -15
- package/scripts/install +8 -0
- package/scripts/install-hooks.sh +54 -1
- package/scripts/install.py +1149 -53
- package/scripts/install_anthropic_key.sh +5 -3
- package/scripts/install_openai_key.sh +5 -3
- package/scripts/skill_trigger_eval.py +13 -2
|
@@ -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>/.claude/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 ``.claude/skills/`` contains the
|
|
55
|
+
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 / ".claude" / "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
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Atomic file-write primitive shared by lockfile-schema-v2 writers.
|
|
2
|
+
|
|
3
|
+
P1.0 of road-to-multi-package-coexistence. Single source of truth for
|
|
4
|
+
crash-safe writes used by ``installed_tools.write_manifest``,
|
|
5
|
+
P1.5 merge tracking, P2.2 uninstall, and P3.x conflict-resolution
|
|
6
|
+
paths. Centralising the mechanism prevents per-phase implementation
|
|
7
|
+
drift (Council amendment, Anthropic 2026-05-12).
|
|
8
|
+
|
|
9
|
+
Guarantees, in order:
|
|
10
|
+
|
|
11
|
+
1. Write to ``<path>.tmp.<pid>.<rand>`` in the same directory as the
|
|
12
|
+
target. Same-directory keeps the final ``os.replace`` atomic on
|
|
13
|
+
every POSIX filesystem we support; cross-fs renames are not atomic.
|
|
14
|
+
2. ``fsync(tmp_fd)`` flushes the file's data + metadata to disk before
|
|
15
|
+
we let the temp file become the visible target.
|
|
16
|
+
3. ``os.replace(tmp, path)`` is the atomic rename. Either the old or
|
|
17
|
+
the new content is visible to readers; never a half-written mix.
|
|
18
|
+
4. ``fsync(parent_dir_fd)`` durably commits the directory entry so a
|
|
19
|
+
crash immediately after the rename does not resurrect the old file
|
|
20
|
+
on next boot. Skipped on platforms where directory fsync is
|
|
21
|
+
unsupported (Windows) — the rename is still atomic from the
|
|
22
|
+
filesystem's perspective, only durability across power loss is
|
|
23
|
+
weaker there.
|
|
24
|
+
|
|
25
|
+
The temp file is always cleaned up on failure, so a raise mid-write
|
|
26
|
+
never leaves orphaned ``.tmp.*`` siblings behind.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Union
|
|
34
|
+
|
|
35
|
+
__all__ = ["write_atomic"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_atomic(
|
|
39
|
+
path: Union[str, Path],
|
|
40
|
+
data: Union[str, bytes],
|
|
41
|
+
*,
|
|
42
|
+
encoding: str = "utf-8",
|
|
43
|
+
) -> Path:
|
|
44
|
+
"""Atomically write ``data`` to ``path``; return the resolved path.
|
|
45
|
+
|
|
46
|
+
``data`` may be ``str`` (encoded via ``encoding``) or ``bytes``
|
|
47
|
+
(written verbatim, ``encoding`` ignored). The parent directory is
|
|
48
|
+
created if missing — callers don't have to ``mkdir`` beforehand.
|
|
49
|
+
|
|
50
|
+
On failure (any exception raised by the OS during write / fsync /
|
|
51
|
+
rename), the temporary file is unlinked and the original target —
|
|
52
|
+
if any — is untouched. The exception propagates so callers can
|
|
53
|
+
distinguish disk-full from permission errors etc.
|
|
54
|
+
"""
|
|
55
|
+
target = Path(path)
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
if isinstance(data, str):
|
|
59
|
+
payload = data.encode(encoding)
|
|
60
|
+
elif isinstance(data, (bytes, bytearray)):
|
|
61
|
+
payload = bytes(data)
|
|
62
|
+
else:
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"write_atomic: data must be str or bytes, got {type(data).__name__}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
68
|
+
prefix=f".{target.name}.tmp.",
|
|
69
|
+
dir=str(target.parent),
|
|
70
|
+
)
|
|
71
|
+
tmp_path = Path(tmp_name)
|
|
72
|
+
try:
|
|
73
|
+
with os.fdopen(fd, "wb") as fh:
|
|
74
|
+
fh.write(payload)
|
|
75
|
+
fh.flush()
|
|
76
|
+
try:
|
|
77
|
+
os.fsync(fh.fileno())
|
|
78
|
+
except OSError:
|
|
79
|
+
# File-level fsync unsupported (e.g. some tmpfs).
|
|
80
|
+
# Continue — os.replace is still atomic.
|
|
81
|
+
pass
|
|
82
|
+
os.replace(tmp_path, target)
|
|
83
|
+
except Exception:
|
|
84
|
+
try:
|
|
85
|
+
tmp_path.unlink()
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
_fsync_dir(target.parent)
|
|
91
|
+
return target
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fsync_dir(directory: Path) -> None:
|
|
95
|
+
"""Best-effort directory fsync; silent no-op on unsupported platforms.
|
|
96
|
+
|
|
97
|
+
Directory fsync is required on POSIX for the rename's durability
|
|
98
|
+
across power loss. Windows does not expose ``open(dir)`` /
|
|
99
|
+
``fsync(dir_fd)`` semantics — the kernel commits the directory
|
|
100
|
+
entry implicitly. We swallow the OSError there rather than fail
|
|
101
|
+
the write.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
dir_fd = os.open(str(directory), os.O_RDONLY)
|
|
105
|
+
except OSError:
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
try:
|
|
109
|
+
os.fsync(dir_fd)
|
|
110
|
+
except OSError:
|
|
111
|
+
# Some filesystems / mounts reject directory fsync.
|
|
112
|
+
# The rename is still atomic — durability is weaker but
|
|
113
|
+
# the write is not corrupted.
|
|
114
|
+
pass
|
|
115
|
+
finally:
|
|
116
|
+
os.close(dir_fd)
|
|
@@ -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,27 @@ _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, honoring the env override.
|
|
53
|
+
"""Return the active lockfile path, honoring the env override.
|
|
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
|
+
Writers always end up at (4) when no lockfile exists yet; readers
|
|
63
|
+
benefit from (3) so pre-2.4 installs keep working while the
|
|
64
|
+
migration shim has not yet run.
|
|
65
|
+
"""
|
|
36
66
|
env = env if env is not None else os.environ
|
|
37
67
|
override = env.get(LOCKFILE_ENV)
|
|
38
68
|
if override:
|
|
39
69
|
return Path(override).expanduser()
|
|
40
|
-
|
|
70
|
+
resolved = user_global_paths.resolve_with_fallback("installed.lock", env=env)
|
|
71
|
+
if resolved is not None:
|
|
72
|
+
return resolved
|
|
73
|
+
return user_global_paths.write_target("installed.lock", env=env)
|
|
41
74
|
|
|
42
75
|
|
|
43
76
|
def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
|