@event4u/agent-config 2.1.0 → 2.2.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/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 +48 -0
- package/README.md +71 -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 +278 -0
- package/docs/decisions/ADR-008-installed-tools-manifest.md +160 -0
- package/docs/decisions/INDEX.md +2 -0
- 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 +59 -3
- package/docs/setup/per-ide/claude-desktop.md +8 -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 +62 -0
- package/scripts/install +43 -10
- package/scripts/install.py +973 -12
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""``agent-config sync`` — replay the installed-tools manifest (ADR-008).
|
|
2
|
+
|
|
3
|
+
Phase 3.3 of road-to-global-first-install.md. Reads
|
|
4
|
+
``agents/installed-tools.lock``, then re-runs the bridge install for every
|
|
5
|
+
tool whose ``bridge_marker`` is missing on disk. Tools whose marker already
|
|
6
|
+
exists are skipped — the typical clone-and-sync flow is therefore idempotent
|
|
7
|
+
on the second invocation.
|
|
8
|
+
|
|
9
|
+
Sync never edits the manifest itself; ``init`` is the only writer. Sync only
|
|
10
|
+
calls the installer with ``--scope`` / ``--tools`` derived from the manifest
|
|
11
|
+
entries, so the manifest is the single source of truth.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
from scripts._lib import installed_tools
|
|
22
|
+
from scripts.install import main as install_main
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _marker_exists(project_root: Path, bridge_marker: str, scope: str) -> bool:
|
|
26
|
+
if not bridge_marker:
|
|
27
|
+
return True # substrate-only entries (rare); treat as present
|
|
28
|
+
if scope == "global":
|
|
29
|
+
target = Path(bridge_marker).expanduser()
|
|
30
|
+
else:
|
|
31
|
+
# Project-scope: relative to the project root unless absolute.
|
|
32
|
+
candidate = Path(bridge_marker)
|
|
33
|
+
target = candidate if candidate.is_absolute() else (project_root / candidate)
|
|
34
|
+
return target.exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _group_by_scope(
|
|
38
|
+
entries: Iterable[dict],
|
|
39
|
+
project_root: Path,
|
|
40
|
+
) -> tuple[dict[str, list[str]], list[tuple[str, str]]]:
|
|
41
|
+
"""Return ({scope: [tool_names]}, [(name, marker_path)]) for missing tools.
|
|
42
|
+
|
|
43
|
+
The second list is the human-readable summary of what will be replayed.
|
|
44
|
+
"""
|
|
45
|
+
missing: dict[str, list[str]] = {"project": [], "global": []}
|
|
46
|
+
surfaced: list[tuple[str, str]] = []
|
|
47
|
+
for entry in entries:
|
|
48
|
+
name = str(entry.get("name", "")).strip()
|
|
49
|
+
scope = str(entry.get("scope", "")).strip()
|
|
50
|
+
bridge_marker = str(entry.get("bridge_marker", "")).strip()
|
|
51
|
+
if not name or scope not in ("project", "global"):
|
|
52
|
+
continue
|
|
53
|
+
if _marker_exists(project_root, bridge_marker, scope):
|
|
54
|
+
continue
|
|
55
|
+
missing[scope].append(name)
|
|
56
|
+
surfaced.append((name, bridge_marker))
|
|
57
|
+
return missing, surfaced
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_install(scope: str, tools: list[str], project_root: Path, *, force: bool, dry_run: bool) -> int:
|
|
61
|
+
if not tools:
|
|
62
|
+
return 0
|
|
63
|
+
argv = [f"--scope={scope}", f"--tools={','.join(sorted(set(tools)))}"]
|
|
64
|
+
if scope == "project":
|
|
65
|
+
argv += [f"--project={project_root}", "--no-smoke"]
|
|
66
|
+
if force:
|
|
67
|
+
argv.append("--force")
|
|
68
|
+
if dry_run:
|
|
69
|
+
argv.append("--skip-bridges")
|
|
70
|
+
return install_main(argv)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse(argv: list[str]) -> argparse.Namespace:
|
|
74
|
+
parser = argparse.ArgumentParser(
|
|
75
|
+
prog="agent-config sync",
|
|
76
|
+
description=(
|
|
77
|
+
"Replay agents/installed-tools.lock — re-installs any tool whose "
|
|
78
|
+
"bridge marker is missing locally. Idempotent."
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--project",
|
|
83
|
+
default=None,
|
|
84
|
+
help="Override the project root (defaults to PROJECT_ROOT or cwd).",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--dry-run",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="Print the planned replay set without touching bridges.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--force",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Forwarded to the installer (overwrites existing bridge files).",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--quiet",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Suppress non-essential output.",
|
|
100
|
+
)
|
|
101
|
+
return parser.parse_args(argv)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _emit(quiet: bool, msg: str) -> None:
|
|
105
|
+
if not quiet:
|
|
106
|
+
print(msg)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str]) -> int:
|
|
110
|
+
opts = _parse(argv)
|
|
111
|
+
project_root = Path(
|
|
112
|
+
opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
|
|
113
|
+
).resolve()
|
|
114
|
+
manifest = installed_tools.manifest_path(project_root)
|
|
115
|
+
data = installed_tools.read_manifest(manifest)
|
|
116
|
+
|
|
117
|
+
if data is None:
|
|
118
|
+
_emit(opts.quiet, f"❌ No manifest found at {manifest}")
|
|
119
|
+
_emit(opts.quiet, " Run `./agent-config init --tools=<id>` to create one.")
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
entries = list(data.get("tools") or [])
|
|
123
|
+
if not entries:
|
|
124
|
+
_emit(opts.quiet, f"ℹ️ Manifest is empty: {manifest}")
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
missing, surfaced = _group_by_scope(entries, project_root)
|
|
128
|
+
total_missing = sum(len(v) for v in missing.values())
|
|
129
|
+
total_present = len(entries) - total_missing
|
|
130
|
+
|
|
131
|
+
_emit(opts.quiet, f"Manifest: {manifest}")
|
|
132
|
+
_emit(opts.quiet, f"Tools: {len(entries)} listed, {total_present} present, {total_missing} missing")
|
|
133
|
+
if total_missing == 0:
|
|
134
|
+
_emit(opts.quiet, "✅ All bridges already installed. Nothing to do.")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
for name, marker in surfaced:
|
|
138
|
+
_emit(opts.quiet, f" • {name:<15} → {marker} (missing)")
|
|
139
|
+
|
|
140
|
+
if opts.dry_run:
|
|
141
|
+
_emit(opts.quiet, "")
|
|
142
|
+
_emit(opts.quiet, "Dry-run: no bridges written.")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
_emit(opts.quiet, "")
|
|
146
|
+
for scope in ("project", "global"):
|
|
147
|
+
tools = missing[scope]
|
|
148
|
+
if not tools:
|
|
149
|
+
continue
|
|
150
|
+
_emit(opts.quiet, f"Replaying scope={scope}: {', '.join(sorted(tools))}")
|
|
151
|
+
rc = _run_install(scope, tools, project_root, force=opts.force, dry_run=False)
|
|
152
|
+
if rc != 0:
|
|
153
|
+
_emit(opts.quiet, f"❌ Installer failed for scope={scope} (rc={rc}); aborting.")
|
|
154
|
+
return rc
|
|
155
|
+
|
|
156
|
+
_emit(opts.quiet, "")
|
|
157
|
+
_emit(opts.quiet, "✅ Sync complete.")
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -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"
|