@event4u/agent-config 2.0.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.
Files changed (41) hide show
  1. package/.agent-src/commands/fix/{pr-bots.md → pr-bot-comments.md} +3 -3
  2. package/.agent-src/commands/fix/{pr.md → pr-comments.md} +6 -6
  3. package/.agent-src/commands/fix/{pr-developers.md → pr-developer-comments.md} +3 -3
  4. package/.agent-src/commands/fix.md +6 -6
  5. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +2 -2
  6. package/.agent-src/rules/no-cheap-questions.md +11 -2
  7. package/.agent-src/skills/readme-writing-package/SKILL.md +24 -0
  8. package/.claude-plugin/marketplace.json +4 -4
  9. package/CHANGELOG.md +79 -0
  10. package/README.md +76 -12
  11. package/docs/architecture.md +2 -2
  12. package/docs/catalog.md +3 -3
  13. package/docs/contracts/command-clusters.md +3 -3
  14. package/docs/contracts/file-ownership-matrix.json +9 -9
  15. package/docs/contracts/tier-3-contrib-plugin.md +129 -0
  16. package/docs/decisions/ADR-007-agent-discovery-scopes.md +278 -0
  17. package/docs/decisions/ADR-008-installed-tools-manifest.md +160 -0
  18. package/docs/decisions/INDEX.md +2 -0
  19. package/docs/getting-started.md +16 -25
  20. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +32 -0
  21. package/docs/guidelines/agent-infra/installed-tools-manifest.md +135 -0
  22. package/docs/installation.md +116 -49
  23. package/docs/migrations/commands-1.15.0.md +3 -3
  24. package/docs/setup/per-ide/claude-desktop.md +8 -4
  25. package/docs/skills-catalog.md +23 -2
  26. package/docs/troubleshooting.md +20 -32
  27. package/llms.txt +22 -1
  28. package/package.json +1 -1
  29. package/scripts/_cli/cmd_export.py +157 -0
  30. package/scripts/_cli/cmd_sync.py +162 -0
  31. package/scripts/_cli/cmd_update.py +23 -1
  32. package/scripts/_cli/cmd_validate.py +164 -0
  33. package/scripts/_lib/installed_lock.py +160 -0
  34. package/scripts/_lib/installed_tools.py +237 -0
  35. package/scripts/agent-config +62 -0
  36. package/scripts/install +68 -13
  37. package/scripts/install.py +984 -33
  38. package/scripts/install.sh +6 -11
  39. package/templates/agent-config-wrapper.sh +40 -25
  40. package/templates/consumer-settings/README.md +2 -2
  41. package/scripts/setup.sh +0 -230
@@ -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")
@@ -98,6 +98,19 @@ Commands:
98
98
  Flags: --check (read-only) | --to <version> (explicit pin)
99
99
  migrate One-shot migration off legacy composer / npm install paths
100
100
  Flags: --dry-run (detect only)
101
+ global Install to user-scope paths (~/.claude/, ~/.cursor/, …)
102
+ Forwards to `scripts/install --global` (ADR-007).
103
+ Flags: --tools=<list> | --ai=<list> | --yes | --force
104
+ export Eject a tool's canonical content into a chosen path
105
+ (real file, no symlink). Idempotent; --force overrides
106
+ content drift. See `./agent-config export --list`.
107
+ Flags: --tool=<id> | --output=<path> | --force | --list
108
+ sync Replay agents/installed-tools.lock — re-installs any
109
+ tool whose bridge marker is missing locally (ADR-008).
110
+ Flags: --dry-run | --force | --project=<path>
111
+ validate Read-only drift detection on the manifest
112
+ (marker missing, scope divergence, version drift).
113
+ Exits 1 on drift. Flags: --quiet | --skip-version-check
101
114
  help Show this help
102
115
  --version, -V Print package version
103
116
 
@@ -126,6 +139,14 @@ Examples:
126
139
  ./agent-config council:estimate prompt.txt
127
140
  ./agent-config council:run prompt.txt --output agents/council-sessions/out.json --confirm
128
141
  ./agent-config council:render agents/council-sessions/out.json
142
+ ./agent-config global --tools=claude-code --yes
143
+ ./agent-config global --ai=cursor,windsurf
144
+ ./agent-config export --list
145
+ ./agent-config export --tool=agents-md --output=AGENTS.md
146
+ ./agent-config export --tool=copilot-instructions --output=.github/copilot-instructions.md
147
+ ./agent-config sync --dry-run
148
+ ./agent-config sync
149
+ ./agent-config validate
129
150
 
130
151
  All commands operate on the CURRENT DIRECTORY (your project root).
131
152
  The CLI is strictly consumer-facing. Maintainer tasks live in Taskfile.yml.
@@ -524,6 +545,43 @@ cmd_migrate() {
524
545
  exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_migrate "$@"
525
546
  }
526
547
 
548
+ # `agent-config global` — user-scope install entry point. Forwards to the
549
+ # bash installer with `--global` set (ADR-007). Phase 1.2 of
550
+ # road-to-global-first-install.md. The bash wrapper handles option parsing
551
+ # and forwards to `scripts/install.py --global`, where `install_global()`
552
+ # currently scaffolds the per-tool anchor paths from USER_SCOPE_PATHS.
553
+ # Concrete writes land in Phase 1.5 (export) and Phase 1.6 (lockfile).
554
+ cmd_global() {
555
+ local script
556
+ script="$(resolve_script "scripts/install")" || return 1
557
+ exec bash "$script" --global "$@"
558
+ }
559
+
560
+ # `agent-config export` — write a tool's canonical content into a
561
+ # user-chosen path. ADR-007 D3 / Phase 1.5 of
562
+ # road-to-global-first-install.md. Replaces the rejected symlink-bridge.
563
+ # See scripts/_cli/cmd_export.py for the registry and idempotency logic.
564
+ cmd_export() {
565
+ require_python3
566
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_export "$@"
567
+ }
568
+
569
+ # `agent-config sync` — replay agents/installed-tools.lock (ADR-008
570
+ # Phase 3.3). Re-installs any tool whose bridge marker is missing on
571
+ # disk. Typical onboarding flow: clone → `./agent-config sync` → done.
572
+ cmd_sync() {
573
+ require_python3
574
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_sync "$@"
575
+ }
576
+
577
+ # `agent-config validate` — read-only drift detection (ADR-008 Phase 3.4).
578
+ # Surfaces marker-missing, scope-divergence, and version-drift; exits 1 on
579
+ # any drift. Never edits the manifest or re-runs the installer.
580
+ cmd_validate() {
581
+ require_python3
582
+ exec env PYTHONPATH="$PACKAGE_ROOT" python3 -m scripts._cli.cmd_validate "$@"
583
+ }
584
+
527
585
  main() {
528
586
  local cmd="${1-}"
529
587
  [[ $# -gt 0 ]] && shift || true
@@ -564,6 +622,10 @@ main() {
564
622
  council:render) cmd_council render "$@" ;;
565
623
  update) cmd_update "$@" ;;
566
624
  migrate) cmd_migrate "$@" ;;
625
+ global) cmd_global "$@" ;;
626
+ export) cmd_export "$@" ;;
627
+ sync) cmd_sync "$@" ;;
628
+ validate) cmd_validate "$@" ;;
567
629
  help|--help|-h|"") usage ;;
568
630
  --version|-V) print_version ;;
569
631
  *)