@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.
@@ -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
  *)
package/scripts/install CHANGED
@@ -17,7 +17,10 @@
17
17
  # --profile <name> Cost profile for bridges (minimal|balanced|full)
18
18
  # --tools <list> Comma-separated tool IDs to install (default: all).
19
19
  # Valid: claude-code,claude-desktop,cursor,windsurf,
20
- # cline,gemini-cli,copilot,augment,aider,codex,all
20
+ # cline,gemini-cli,copilot,augment,aider,codex,
21
+ # roocode,continue,kilocode,zed,jetbrains,kiro,all
22
+ # --ai <list> Alias for --tools (same IDs). When both are passed
23
+ # the comma-separated values are unioned.
21
24
  # --list-tools Print supported tool IDs with descriptions, then exit
22
25
  # --yes, -y Non-interactive mode: do not prompt (default for non-TTY)
23
26
  # --force Overwrite existing bridge files
@@ -26,12 +29,22 @@
26
29
  # --quiet Suppress non-error output
27
30
  # --skip-sync Skip payload sync (install.sh)
28
31
  # --skip-bridges Skip bridge files (install.py)
32
+ # --global Install to user-scope paths (~/.claude/, ~/.cursor/, …)
33
+ # instead of project-locally. Implies --skip-sync (the
34
+ # payload sync stage is project-only). See ADR-007.
35
+ # --scope <mode> Override scope detection: auto | project | global | prompt.
36
+ # auto = honor multi-signal detection (Phase 1.3)
37
+ # project = force project-local install
38
+ # global = force user-scope install (same as --global)
39
+ # prompt = always show the 3-option chooser
40
+ # --custom-path <d> Use <d> as the project root when prompted; rejected with
41
+ # --scope=global / --global.
29
42
  # --help, -h Show this help
30
43
  #
31
44
  # Examples:
32
45
  # bash scripts/install # everything (default)
33
46
  # bash scripts/install --tools=claude-code,cursor # only those two
34
- # bash scripts/install --tools=cursor --yes # CI-friendly
47
+ # bash scripts/install --ai=cursor --yes # alias form (CI-friendly)
35
48
  # bash scripts/install --list-tools # show catalog
36
49
 
37
50
  set -uo pipefail
@@ -53,12 +66,15 @@ QUIET=false
53
66
  SKIP_SYNC=false
54
67
  SKIP_BRIDGES=false
55
68
  LIST_TOOLS=false
69
+ GLOBAL=false
70
+ SCOPE=""
71
+ CUSTOM_PATH=""
56
72
 
57
73
  # Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
58
- VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex all"
74
+ VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
59
75
 
60
76
  show_help() {
61
- sed -n '3,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
77
+ sed -n '3,48p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
62
78
  }
63
79
 
64
80
  list_tools() {
@@ -66,19 +82,26 @@ list_tools() {
66
82
  Supported --tools IDs (default: all):
67
83
 
68
84
  claude-code .claude/rules, .claude/skills, .claude/commands, .claude/settings.json
69
- claude-desktop ~/Library/Application Support/Claude/ (global, see Phase 4 docs)
85
+ claude-desktop .claude-desktop/agent-config.md marker (global-scope tool see ADR-007)
70
86
  cursor .cursor/rules, .cursor/commands (legacy .cursorrules also written)
71
87
  windsurf .windsurf/rules, .windsurf/workflows (legacy .windsurfrules also written)
72
88
  cline .clinerules/ symlinks
73
89
  gemini-cli GEMINI.md, .gemini/settings.json
74
90
  copilot .github/copilot-instructions.md, .vscode/settings.json
75
91
  augment .augment/ payload + settings (substrate — recommended for every install)
76
- aider AGENTS.md (Linux Foundation cross-tool contract; always written)
77
- codex AGENTS.md (same as aider — no extra action)
92
+ aider .aider/agent-config.md marker (wire via `read:` in .aider.conf.yml)
93
+ codex .codex/agent-config.md marker (Codex reads AGENTS.md directly)
94
+ roocode .roo/rules/agent-config.md marker (Roo Code auto-discovery)
95
+ continue .continue/rules/agent-config.md marker (Continue.dev auto-discovery)
96
+ kilocode .kilocode/rules/agent-config.md marker (Kilo Code auto-discovery)
97
+ zed .zed/agent-config.md marker (Zed reads .rules at project root)
98
+ jetbrains .jetbrains/agent-config.md marker (JetBrains AI Assistant)
99
+ kiro .kiro/steering/agent-config.md marker (Kiro auto-discovery)
78
100
  all every ID above (the default; backward-compatible)
79
101
 
80
102
  Examples:
81
103
  --tools=claude-code,cursor project-local install for those two surfaces
104
+ --ai=cursor alias for --tools=cursor
82
105
  --tools=all equivalent to omitting the flag
83
106
  EOF
84
107
  }
@@ -110,8 +133,10 @@ while [[ $# -gt 0 ]]; do
110
133
  --target=*) TARGET_DIR="${1#*=}"; shift ;;
111
134
  --profile) PROFILE="$2"; shift 2 ;;
112
135
  --profile=*) PROFILE="${1#*=}"; shift ;;
113
- --tools) TOOLS="$2"; TOOLS_EXPLICIT=true; shift 2 ;;
114
- --tools=*) TOOLS="${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
136
+ --tools) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
137
+ --tools=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
138
+ --ai) TOOLS="${TOOLS:+$TOOLS,}$2"; TOOLS_EXPLICIT=true; shift 2 ;;
139
+ --ai=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
115
140
  --list-tools) LIST_TOOLS=true; shift ;;
116
141
  --yes|-y) YES=true; shift ;;
117
142
  --force) FORCE=true; shift ;;
@@ -120,6 +145,11 @@ while [[ $# -gt 0 ]]; do
120
145
  --quiet) QUIET=true; shift ;;
121
146
  --skip-sync) SKIP_SYNC=true; shift ;;
122
147
  --skip-bridges) SKIP_BRIDGES=true; shift ;;
148
+ --global) GLOBAL=true; SKIP_SYNC=true; shift ;;
149
+ --scope) SCOPE="$2"; shift 2 ;;
150
+ --scope=*) SCOPE="${1#*=}"; shift ;;
151
+ --custom-path) CUSTOM_PATH="$2"; shift 2 ;;
152
+ --custom-path=*) CUSTOM_PATH="${1#*=}"; shift ;;
123
153
  --help|-h) show_help; exit 0 ;;
124
154
  *) err "Unknown argument: $1"; show_help >&2; exit 1 ;;
125
155
  esac
@@ -138,7 +168,7 @@ fi
138
168
  # Otherwise we fall through to the backward-compatible "all" default.
139
169
  prompt_tools() {
140
170
  local choice picked tool i=0
141
- local -a menu=(claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex)
171
+ local -a menu=(claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro)
142
172
  echo ""
143
173
  echo " 📦 Pick the tools to install (comma-separated numbers, blank = all):"
144
174
  for tool in "${menu[@]}"; do
@@ -263,6 +293,9 @@ run_bridges() {
263
293
  [[ -n "$PROFILE" ]] && args+=(--profile="$PROFILE")
264
294
  $FORCE && args+=(--force)
265
295
  $QUIET && args+=(--quiet)
296
+ $GLOBAL && args+=(--global)
297
+ [[ -n "$SCOPE" ]] && args+=(--scope="$SCOPE")
298
+ [[ -n "$CUSTOM_PATH" ]] && args+=(--custom-path="$CUSTOM_PATH")
266
299
  args+=(--tools="$TOOLS")
267
300
  "$python_bin" "$INSTALL_PY" "${args[@]}"
268
301
  }