@event4u/agent-config 2.1.0 → 2.2.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/rules/no-cheap-questions.md +11 -2
- package/.agent-src/skills/readme-writing-package/SKILL.md +24 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +71 -6
- package/docs/DISTRIBUTION_CHECKLIST.md +7 -6
- package/docs/architecture.md +1 -1
- package/docs/contracts/tier-3-contrib-plugin.md +129 -0
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +286 -0
- package/docs/decisions/ADR-008-installed-tools-manifest.md +160 -0
- package/docs/decisions/INDEX.md +2 -0
- package/docs/getting-started.md +1 -1
- package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +32 -0
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +135 -0
- package/docs/installation.md +83 -27
- package/docs/setup/per-ide/aider.md +1 -1
- package/docs/setup/per-ide/claude-code.md +1 -1
- package/docs/setup/per-ide/claude-desktop.md +8 -4
- package/docs/setup/per-ide/cline.md +2 -2
- package/docs/setup/per-ide/codex.md +1 -1
- package/docs/setup/per-ide/copilot.md +2 -2
- package/docs/setup/per-ide/cursor.md +2 -2
- package/docs/setup/per-ide/gemini-cli.md +1 -1
- package/docs/setup/per-ide/windsurf.md +2 -2
- package/docs/troubleshooting.md +4 -4
- package/package.json +1 -1
- package/scripts/_cli/cmd_export.py +157 -0
- package/scripts/_cli/cmd_sync.py +162 -0
- package/scripts/_cli/cmd_update.py +23 -1
- package/scripts/_cli/cmd_validate.py +164 -0
- package/scripts/_lib/installed_lock.py +160 -0
- package/scripts/_lib/installed_tools.py +237 -0
- package/scripts/agent-config +78 -1
- package/scripts/install +43 -10
- package/scripts/install.py +975 -14
- package/templates/agent-config-wrapper.sh +1 -1
- package/templates/consumer-settings/README.md +1 -1
- package/templates/marketing-copy.yml +6 -5
|
@@ -36,7 +36,7 @@ from datetime import datetime, timezone
|
|
|
36
36
|
from pathlib import Path
|
|
37
37
|
from typing import Optional
|
|
38
38
|
|
|
39
|
-
from scripts._lib import update_check
|
|
39
|
+
from scripts._lib import installed_lock, update_check
|
|
40
40
|
from scripts._lib.agent_settings import (
|
|
41
41
|
DEFAULT_PROJECT_FILE,
|
|
42
42
|
_resolve_cascade_paths,
|
|
@@ -205,9 +205,31 @@ def main(
|
|
|
205
205
|
|
|
206
206
|
cache_warmer(latest)
|
|
207
207
|
_refresh_state(latest, latest, state_path)
|
|
208
|
+
_refresh_global_lockfile(latest, out=out)
|
|
208
209
|
return 0
|
|
209
210
|
|
|
210
211
|
|
|
212
|
+
def _refresh_global_lockfile(version: str, *, out=sys.stdout) -> None:
|
|
213
|
+
"""Update ``~/.config/agent-config/installed.lock`` if it exists.
|
|
214
|
+
|
|
215
|
+
Phase 1.6 — the lockfile is only present when the user has run a
|
|
216
|
+
global install; we never create one here, but we keep it in lockstep
|
|
217
|
+
when ``update`` flips the pin. Atomic write goes through
|
|
218
|
+
``installed_lock.write_lockfile``.
|
|
219
|
+
"""
|
|
220
|
+
lock_path = installed_lock.lockfile_path()
|
|
221
|
+
existing = installed_lock.read_lockfile(path=lock_path)
|
|
222
|
+
if existing is None:
|
|
223
|
+
return
|
|
224
|
+
recorded = existing.get("agent_config_version")
|
|
225
|
+
tools = list(existing.get("tools", []))
|
|
226
|
+
if recorded == version:
|
|
227
|
+
print(f"ℹ️ {lock_path} already records {version}.", file=out)
|
|
228
|
+
return
|
|
229
|
+
installed_lock.write_lockfile(version, tools, path=lock_path)
|
|
230
|
+
print(f"✅ Refreshed global lockfile at {lock_path}.", file=out)
|
|
231
|
+
|
|
232
|
+
|
|
211
233
|
def _detect_installed_version() -> str:
|
|
212
234
|
"""Read ``version`` from the package's own ``package.json``."""
|
|
213
235
|
pkg_json = Path(__file__).resolve().parents[2] / "package.json"
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""``agent-config validate`` — drift detection for the installed-tools manifest.
|
|
2
|
+
|
|
3
|
+
Phase 3.4 of road-to-global-first-install.md (ADR-008). Read-only check —
|
|
4
|
+
never edits the manifest, never re-runs the installer. Exits non-zero if any
|
|
5
|
+
drift is found so CI can gate on it. Surfaces three drift kinds documented in
|
|
6
|
+
ADR-008 §Lifecycle:
|
|
7
|
+
|
|
8
|
+
1. **marker_missing** — recorded ``bridge_marker`` path does not exist.
|
|
9
|
+
2. **scope_divergence** — recorded scope is ``project`` but the marker only
|
|
10
|
+
exists at the user-scope anchor (or vice versa); the manifest is lying
|
|
11
|
+
about where the tool actually lives.
|
|
12
|
+
3. **version_drift** — manifest's ``agent_config_version`` no longer
|
|
13
|
+
matches the package's currently-installed version (single repo-level
|
|
14
|
+
check, surfaced once not per-tool).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
from scripts._lib import installed_lock, installed_tools
|
|
25
|
+
from scripts.install import PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_marker(project_root: Path, bridge_marker: str, scope: str) -> Path:
|
|
29
|
+
if scope == "global":
|
|
30
|
+
return Path(bridge_marker).expanduser()
|
|
31
|
+
candidate = Path(bridge_marker)
|
|
32
|
+
return candidate if candidate.is_absolute() else (project_root / candidate)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _counterpart_path(project_root: Path, tool_id: str, scope: str) -> Path | None:
|
|
36
|
+
"""Return the *other* scope's canonical marker path, or None if unknown."""
|
|
37
|
+
if scope == "project":
|
|
38
|
+
anchor = USER_SCOPE_PATHS.get(tool_id)
|
|
39
|
+
return Path(anchor).expanduser() if anchor else None
|
|
40
|
+
rel = PROJECT_BRIDGE_MARKERS.get(tool_id)
|
|
41
|
+
return (project_root / rel) if rel else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_entry(project_root: Path, entry: dict) -> list[dict]:
|
|
45
|
+
name = str(entry.get("name", "")).strip()
|
|
46
|
+
scope = str(entry.get("scope", "")).strip()
|
|
47
|
+
bridge_marker = str(entry.get("bridge_marker", "")).strip()
|
|
48
|
+
issues: list[dict] = []
|
|
49
|
+
if not name or scope not in ("project", "global") or not bridge_marker:
|
|
50
|
+
issues.append({
|
|
51
|
+
"kind": "manifest_corrupt",
|
|
52
|
+
"name": name or "<unknown>",
|
|
53
|
+
"detail": f"entry missing required fields (scope={scope!r}, marker={bridge_marker!r})",
|
|
54
|
+
})
|
|
55
|
+
return issues
|
|
56
|
+
target = _resolve_marker(project_root, bridge_marker, scope)
|
|
57
|
+
if not target.exists():
|
|
58
|
+
counterpart = _counterpart_path(project_root, name, scope)
|
|
59
|
+
if counterpart is not None and counterpart.exists():
|
|
60
|
+
other_scope = "global" if scope == "project" else "project"
|
|
61
|
+
issues.append({
|
|
62
|
+
"kind": "scope_divergence",
|
|
63
|
+
"name": name,
|
|
64
|
+
"detail": (
|
|
65
|
+
f"recorded scope={scope} ({target}) is missing, but "
|
|
66
|
+
f"counterpart at scope={other_scope} ({counterpart}) exists"
|
|
67
|
+
),
|
|
68
|
+
})
|
|
69
|
+
else:
|
|
70
|
+
issues.append({
|
|
71
|
+
"kind": "marker_missing",
|
|
72
|
+
"name": name,
|
|
73
|
+
"detail": f"bridge_marker not found: {target}",
|
|
74
|
+
})
|
|
75
|
+
return issues
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _version_drift(manifest_version: str, current_version: str) -> dict | None:
|
|
79
|
+
if not manifest_version or not current_version:
|
|
80
|
+
return None
|
|
81
|
+
if manifest_version != current_version:
|
|
82
|
+
return {
|
|
83
|
+
"kind": "version_drift",
|
|
84
|
+
"name": "<manifest>",
|
|
85
|
+
"detail": (
|
|
86
|
+
f"manifest recorded agent_config_version={manifest_version}; "
|
|
87
|
+
f"currently installed package is {current_version}"
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
94
|
+
parser = argparse.ArgumentParser(
|
|
95
|
+
prog="agent-config validate",
|
|
96
|
+
description=(
|
|
97
|
+
"Read-only drift detection for agents/installed-tools.lock. "
|
|
98
|
+
"Exits 1 if any drift is found."
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument("--project", default=None, help="Override project root.")
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--quiet", action="store_true", help="Suppress non-essential output.",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--skip-version-check",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Skip the manifest-vs-package version drift check.",
|
|
109
|
+
)
|
|
110
|
+
return parser.parse_args(argv)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _emit(quiet: bool, msg: str) -> None:
|
|
114
|
+
if not quiet:
|
|
115
|
+
print(msg)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _format(issue: dict) -> str:
|
|
119
|
+
return f" ❌ [{issue['kind']}] {issue['name']}: {issue['detail']}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str]) -> int:
|
|
123
|
+
opts = _parse(argv)
|
|
124
|
+
project_root = Path(
|
|
125
|
+
opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
|
|
126
|
+
).resolve()
|
|
127
|
+
manifest = installed_tools.manifest_path(project_root)
|
|
128
|
+
data = installed_tools.read_manifest(manifest)
|
|
129
|
+
|
|
130
|
+
if data is None:
|
|
131
|
+
_emit(opts.quiet, f"❌ No manifest found at {manifest}")
|
|
132
|
+
_emit(opts.quiet, " Run `./agent-config init --tools=<id>` to create one.")
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
entries = list(data.get("tools") or [])
|
|
136
|
+
issues: list[dict] = []
|
|
137
|
+
for entry in entries:
|
|
138
|
+
issues.extend(_check_entry(project_root, entry))
|
|
139
|
+
|
|
140
|
+
if not opts.skip_version_check:
|
|
141
|
+
manifest_version = str(data.get("agent_config_version", "")).strip()
|
|
142
|
+
current_version = installed_lock.current_package_version()
|
|
143
|
+
drift = _version_drift(manifest_version, current_version)
|
|
144
|
+
if drift is not None:
|
|
145
|
+
issues.append(drift)
|
|
146
|
+
|
|
147
|
+
_emit(opts.quiet, f"Manifest: {manifest}")
|
|
148
|
+
_emit(opts.quiet, f"Tools: {len(entries)} entries")
|
|
149
|
+
|
|
150
|
+
if not issues:
|
|
151
|
+
_emit(opts.quiet, "✅ No drift detected.")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
_emit(opts.quiet, f"Drift: {len(issues)} issue(s)")
|
|
155
|
+
for issue in issues:
|
|
156
|
+
_emit(opts.quiet, _format(issue))
|
|
157
|
+
_emit(opts.quiet, "")
|
|
158
|
+
_emit(opts.quiet, "Run `./agent-config sync` to replay missing bridges, or")
|
|
159
|
+
_emit(opts.quiet, "`./agent-config init --tools=<id> --force` to refresh the manifest.")
|
|
160
|
+
return 1
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Global-install lockfile at ``~/.config/agent-config/installed.lock``.
|
|
2
|
+
|
|
3
|
+
Phase 1.6 of road-to-global-first-install (ADR-007 D5). Records the
|
|
4
|
+
package version that performed the most recent user-scope install plus
|
|
5
|
+
the tools that were scaffolded. ``init --global`` reads this file: on
|
|
6
|
+
version mismatch the install refuses unless ``--force`` is passed; the
|
|
7
|
+
``update`` subcommand refreshes the entry in lockstep with the pin
|
|
8
|
+
flip in ``.agent-settings.yml``.
|
|
9
|
+
|
|
10
|
+
The schema is intentionally minimal YAML so the module can read and
|
|
11
|
+
write without depending on ``pyyaml``. Atomic writes go through
|
|
12
|
+
``tempfile + os.replace`` per ADR-007 risk-mitigation row.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
LOCKFILE_ENV = "AGENT_CONFIG_INSTALLED_LOCK"
|
|
25
|
+
DEFAULT_LOCKFILE = Path.home() / ".config" / "agent-config" / "installed.lock"
|
|
26
|
+
SCHEMA_VERSION = 1
|
|
27
|
+
|
|
28
|
+
_VERSION_RE = re.compile(r'^\s*agent_config_version\s*:\s*"?([^"\s]+)"?\s*$')
|
|
29
|
+
_SCHEMA_RE = re.compile(r"^\s*schema_version\s*:\s*(\d+)\s*$")
|
|
30
|
+
_INSTALLED_AT_RE = re.compile(r'^\s*installed_at\s*:\s*"?([^"\s]+)"?\s*$')
|
|
31
|
+
_TOOL_RE = re.compile(r"^\s*-\s*([A-Za-z0-9_\-.]+)\s*$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def lockfile_path(env: Optional[dict] = None) -> Path:
|
|
35
|
+
"""Return the active lockfile path, honoring the env override."""
|
|
36
|
+
env = env if env is not None else os.environ
|
|
37
|
+
override = env.get(LOCKFILE_ENV)
|
|
38
|
+
if override:
|
|
39
|
+
return Path(override).expanduser()
|
|
40
|
+
return DEFAULT_LOCKFILE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_lockfile(path: Optional[Path] = None) -> Optional[dict]:
|
|
44
|
+
"""Parse ``path`` (or the default) into a dict; return ``None`` if absent.
|
|
45
|
+
|
|
46
|
+
Tolerates partial / malformed files: missing keys yield missing dict
|
|
47
|
+
entries rather than raising, so a hand-edited corrupt file does not
|
|
48
|
+
brick ``init``.
|
|
49
|
+
"""
|
|
50
|
+
target = path or lockfile_path()
|
|
51
|
+
try:
|
|
52
|
+
text = target.read_text(encoding="utf-8")
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
return None
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
data: dict = {"tools": []}
|
|
59
|
+
in_tools = False
|
|
60
|
+
for raw_line in text.splitlines():
|
|
61
|
+
if _SCHEMA_RE.match(raw_line):
|
|
62
|
+
data["schema_version"] = int(_SCHEMA_RE.match(raw_line).group(1))
|
|
63
|
+
in_tools = False
|
|
64
|
+
continue
|
|
65
|
+
if _VERSION_RE.match(raw_line):
|
|
66
|
+
data["agent_config_version"] = _VERSION_RE.match(raw_line).group(1)
|
|
67
|
+
in_tools = False
|
|
68
|
+
continue
|
|
69
|
+
if _INSTALLED_AT_RE.match(raw_line):
|
|
70
|
+
data["installed_at"] = _INSTALLED_AT_RE.match(raw_line).group(1)
|
|
71
|
+
in_tools = False
|
|
72
|
+
continue
|
|
73
|
+
if raw_line.strip().startswith("tools:"):
|
|
74
|
+
in_tools = True
|
|
75
|
+
continue
|
|
76
|
+
if in_tools:
|
|
77
|
+
m = _TOOL_RE.match(raw_line)
|
|
78
|
+
if m:
|
|
79
|
+
data["tools"].append(m.group(1))
|
|
80
|
+
elif raw_line.strip() and not raw_line.startswith((" ", "\t", "-")):
|
|
81
|
+
in_tools = False
|
|
82
|
+
return data
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render(version: str, tools: list[str], installed_at: str) -> str:
|
|
86
|
+
lines = [
|
|
87
|
+
f"schema_version: {SCHEMA_VERSION}",
|
|
88
|
+
f'agent_config_version: "{version}"',
|
|
89
|
+
f'installed_at: "{installed_at}"',
|
|
90
|
+
"tools:",
|
|
91
|
+
]
|
|
92
|
+
for tool in tools:
|
|
93
|
+
lines.append(f" - {tool}")
|
|
94
|
+
return "\n".join(lines) + "\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def write_lockfile(
|
|
98
|
+
version: str,
|
|
99
|
+
tools: list[str],
|
|
100
|
+
*,
|
|
101
|
+
path: Optional[Path] = None,
|
|
102
|
+
now: Optional[datetime] = None,
|
|
103
|
+
) -> Path:
|
|
104
|
+
"""Atomically write the lockfile; return the path written."""
|
|
105
|
+
target = path or lockfile_path()
|
|
106
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
stamp = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
108
|
+
rendered = _render(version, sorted(set(tools)), stamp)
|
|
109
|
+
# Atomic write: tempfile in the same dir + os.replace. The same-dir
|
|
110
|
+
# constraint keeps the rename atomic across all POSIX filesystems
|
|
111
|
+
# and Windows when the file already exists.
|
|
112
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
113
|
+
prefix=".installed.lock.", dir=str(target.parent), text=False
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
117
|
+
fh.write(rendered)
|
|
118
|
+
os.replace(tmp_name, target)
|
|
119
|
+
except Exception:
|
|
120
|
+
try:
|
|
121
|
+
os.unlink(tmp_name)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
raise
|
|
125
|
+
return target
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_version(
|
|
129
|
+
installed_version: str,
|
|
130
|
+
*,
|
|
131
|
+
path: Optional[Path] = None,
|
|
132
|
+
) -> tuple[bool, Optional[str]]:
|
|
133
|
+
"""Compare ``installed_version`` against the lockfile's recorded version.
|
|
134
|
+
|
|
135
|
+
Returns ``(ok, recorded_version_or_none)``:
|
|
136
|
+
* ``(True, None)`` — no lockfile yet; ``init`` may proceed.
|
|
137
|
+
* ``(True, vX)`` — matches; ``init`` may proceed.
|
|
138
|
+
* ``(False, vY)`` — mismatch; caller must refuse without ``--force``.
|
|
139
|
+
"""
|
|
140
|
+
existing = read_lockfile(path=path)
|
|
141
|
+
if existing is None:
|
|
142
|
+
return True, None
|
|
143
|
+
recorded = existing.get("agent_config_version")
|
|
144
|
+
if not recorded:
|
|
145
|
+
return True, None
|
|
146
|
+
return (recorded == installed_version, recorded)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def current_package_version(repo_root: Optional[Path] = None) -> str:
|
|
150
|
+
"""Read ``version`` from the package's own ``package.json``."""
|
|
151
|
+
if repo_root is None:
|
|
152
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
153
|
+
try:
|
|
154
|
+
data = json.loads((repo_root / "package.json").read_text(encoding="utf-8"))
|
|
155
|
+
version = data.get("version")
|
|
156
|
+
if isinstance(version, str) and version.strip():
|
|
157
|
+
return version.strip()
|
|
158
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
159
|
+
pass
|
|
160
|
+
return "0.0.0"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Project-scope installed-tools manifest at ``agents/installed-tools.lock``.
|
|
2
|
+
|
|
3
|
+
Phase 3 of road-to-global-first-install (ADR-008). Committed
|
|
4
|
+
bill-of-materials for AI tooling a project depends on. Sibling to the
|
|
5
|
+
global lockfile (``installed_lock.py``) but architecturally distinct:
|
|
6
|
+
|
|
7
|
+
- ``installed_lock.py`` lives in ``~/.config/agent-config/`` and tracks
|
|
8
|
+
the user-scope environment (a single ``agent_config_version`` and a
|
|
9
|
+
flat ``tools[]`` list).
|
|
10
|
+
- ``installed_tools.py`` lives in ``agents/`` and tracks **per-project**
|
|
11
|
+
tooling with richer per-entry metadata (``scope``, ``bridge_marker``,
|
|
12
|
+
``installed_at``).
|
|
13
|
+
|
|
14
|
+
The file is machine-managed: ``init`` appends / merges; ``sync`` replays;
|
|
15
|
+
``validate`` drift-checks. Schema is YAML; ``pyyaml`` is used when
|
|
16
|
+
available, otherwise a constrained manual parser handles the documented
|
|
17
|
+
schema (no anchors, no flow style, single-level nesting under
|
|
18
|
+
``tools``).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import tempfile
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
MANIFEST_ENV = "AGENT_CONFIG_INSTALLED_TOOLS"
|
|
30
|
+
DEFAULT_MANIFEST_RELATIVE = Path("agents") / "installed-tools.lock"
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
_VALID_SCOPES = ("global", "project")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def manifest_path(project_root: Path, env: Optional[dict] = None) -> Path:
|
|
37
|
+
"""Return the active manifest path, honoring the env override."""
|
|
38
|
+
env = env if env is not None else os.environ
|
|
39
|
+
override = env.get(MANIFEST_ENV)
|
|
40
|
+
if override:
|
|
41
|
+
return Path(override).expanduser()
|
|
42
|
+
return project_root / DEFAULT_MANIFEST_RELATIVE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Read
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_TOP_KEY_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"?([^"\n]*?)"?\s*$')
|
|
50
|
+
_LIST_DASH_RE = re.compile(r"^\s*-\s*(.+?)\s*$")
|
|
51
|
+
_INDENT_KEY_RE = re.compile(r'^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"?([^"\n]*?)"?\s*$')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_manifest(path: Path) -> Optional[dict[str, Any]]:
|
|
55
|
+
"""Parse the manifest into a dict; return ``None`` if absent.
|
|
56
|
+
|
|
57
|
+
Tolerates partial / malformed files: missing keys yield missing dict
|
|
58
|
+
entries rather than raising, so a corrupted file does not brick
|
|
59
|
+
``init``.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
text = path.read_text(encoding="utf-8")
|
|
63
|
+
except (FileNotFoundError, OSError):
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
import yaml # type: ignore[import-untyped]
|
|
67
|
+
data = yaml.safe_load(text) or {}
|
|
68
|
+
if isinstance(data, dict):
|
|
69
|
+
data.setdefault("tools", [])
|
|
70
|
+
return data
|
|
71
|
+
except ImportError:
|
|
72
|
+
pass
|
|
73
|
+
except Exception:
|
|
74
|
+
# Fall through to the manual parser; corrupt YAML is recoverable
|
|
75
|
+
# from our strict schema as long as the top-level shape holds.
|
|
76
|
+
pass
|
|
77
|
+
return _parse_manual(text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_manual(text: str) -> dict[str, Any]:
|
|
81
|
+
data: dict[str, Any] = {"tools": []}
|
|
82
|
+
in_tools = False
|
|
83
|
+
current: Optional[dict[str, Any]] = None
|
|
84
|
+
for raw in text.splitlines():
|
|
85
|
+
stripped = raw.strip()
|
|
86
|
+
if not stripped or stripped.startswith("#"):
|
|
87
|
+
continue
|
|
88
|
+
if stripped == "tools:":
|
|
89
|
+
in_tools = True
|
|
90
|
+
current = None
|
|
91
|
+
continue
|
|
92
|
+
if in_tools:
|
|
93
|
+
m = _LIST_DASH_RE.match(raw)
|
|
94
|
+
if m:
|
|
95
|
+
first = m.group(1)
|
|
96
|
+
current = {}
|
|
97
|
+
data["tools"].append(current)
|
|
98
|
+
# Could be inline like `- name: foo` — handle that.
|
|
99
|
+
inline = _TOP_KEY_RE.match(first)
|
|
100
|
+
if inline:
|
|
101
|
+
current[inline.group(1)] = inline.group(2)
|
|
102
|
+
continue
|
|
103
|
+
mk = _INDENT_KEY_RE.match(raw)
|
|
104
|
+
if mk and current is not None:
|
|
105
|
+
current[mk.group(1)] = mk.group(2)
|
|
106
|
+
continue
|
|
107
|
+
m_top = _TOP_KEY_RE.match(raw)
|
|
108
|
+
if m_top:
|
|
109
|
+
key, value = m_top.group(1), m_top.group(2)
|
|
110
|
+
if key == "schema_version":
|
|
111
|
+
try:
|
|
112
|
+
data[key] = int(value)
|
|
113
|
+
except ValueError:
|
|
114
|
+
data[key] = value
|
|
115
|
+
else:
|
|
116
|
+
data[key] = value
|
|
117
|
+
in_tools = False
|
|
118
|
+
current = None
|
|
119
|
+
return data
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Write
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render(
|
|
128
|
+
version: str,
|
|
129
|
+
tools: list[dict[str, Any]],
|
|
130
|
+
) -> str:
|
|
131
|
+
lines = [
|
|
132
|
+
f"schema_version: {SCHEMA_VERSION}",
|
|
133
|
+
f'agent_config_version: "{version}"',
|
|
134
|
+
"tools:",
|
|
135
|
+
]
|
|
136
|
+
for tool in tools:
|
|
137
|
+
lines.append(f" - name: {tool['name']}")
|
|
138
|
+
lines.append(f" scope: {tool['scope']}")
|
|
139
|
+
lines.append(f" bridge_marker: {tool['bridge_marker']}")
|
|
140
|
+
lines.append(f' installed_at: "{tool["installed_at"]}"')
|
|
141
|
+
return "\n".join(lines) + "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def write_manifest(
|
|
145
|
+
path: Path,
|
|
146
|
+
version: str,
|
|
147
|
+
tools: list[dict[str, Any]],
|
|
148
|
+
) -> Path:
|
|
149
|
+
"""Atomically write the manifest; return the path written."""
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
rendered = _render(version, tools)
|
|
152
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
153
|
+
prefix=".installed-tools.lock.", dir=str(path.parent), text=False
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
157
|
+
fh.write(rendered)
|
|
158
|
+
os.replace(tmp_name, path)
|
|
159
|
+
except Exception:
|
|
160
|
+
try:
|
|
161
|
+
os.unlink(tmp_name)
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
raise
|
|
165
|
+
return path
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Mutation helpers
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ScopeMismatchError(RuntimeError):
|
|
174
|
+
"""Raised when an existing manifest entry conflicts with the new scope."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, name: str, recorded_scope: str, new_scope: str):
|
|
177
|
+
super().__init__(
|
|
178
|
+
f"tool {name!r} is committed as scope={recorded_scope}; "
|
|
179
|
+
f"refusing to change it to scope={new_scope} without --force"
|
|
180
|
+
)
|
|
181
|
+
self.name = name
|
|
182
|
+
self.recorded_scope = recorded_scope
|
|
183
|
+
self.new_scope = new_scope
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def upsert_tool(
|
|
187
|
+
existing: list[dict[str, Any]],
|
|
188
|
+
*,
|
|
189
|
+
name: str,
|
|
190
|
+
scope: str,
|
|
191
|
+
bridge_marker: str,
|
|
192
|
+
installed_at: Optional[str] = None,
|
|
193
|
+
force: bool = False,
|
|
194
|
+
) -> list[dict[str, Any]]:
|
|
195
|
+
"""Return a new tools list with ``name`` added or refreshed.
|
|
196
|
+
|
|
197
|
+
Idempotency rules from ADR-008 §Lifecycle:
|
|
198
|
+
* Same name, same scope → no-op (timestamp preserved).
|
|
199
|
+
* Same name, different scope → raise ``ScopeMismatchError`` unless
|
|
200
|
+
``force=True``, in which case the entry is rewritten.
|
|
201
|
+
* New name → appended in install order (not alphabetised).
|
|
202
|
+
"""
|
|
203
|
+
if scope not in _VALID_SCOPES:
|
|
204
|
+
raise ValueError(f"scope must be one of {_VALID_SCOPES}: {scope!r}")
|
|
205
|
+
stamp = installed_at or _today()
|
|
206
|
+
result: list[dict[str, Any]] = []
|
|
207
|
+
found = False
|
|
208
|
+
for entry in existing:
|
|
209
|
+
if entry.get("name") == name:
|
|
210
|
+
found = True
|
|
211
|
+
recorded = str(entry.get("scope", ""))
|
|
212
|
+
if recorded == scope:
|
|
213
|
+
# Idempotent no-op — preserve original installed_at.
|
|
214
|
+
result.append(entry)
|
|
215
|
+
continue
|
|
216
|
+
if not force:
|
|
217
|
+
raise ScopeMismatchError(name, recorded, scope)
|
|
218
|
+
result.append({
|
|
219
|
+
"name": name,
|
|
220
|
+
"scope": scope,
|
|
221
|
+
"bridge_marker": bridge_marker,
|
|
222
|
+
"installed_at": stamp,
|
|
223
|
+
})
|
|
224
|
+
continue
|
|
225
|
+
result.append(entry)
|
|
226
|
+
if not found:
|
|
227
|
+
result.append({
|
|
228
|
+
"name": name,
|
|
229
|
+
"scope": scope,
|
|
230
|
+
"bridge_marker": bridge_marker,
|
|
231
|
+
"installed_at": stamp,
|
|
232
|
+
})
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _today() -> str:
|
|
237
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|