@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
package/scripts/install.py
CHANGED
|
@@ -107,7 +107,7 @@ def detect_package_root(project_root: Path) -> Path:
|
|
|
107
107
|
|
|
108
108
|
fail(
|
|
109
109
|
"Could not find agent-config package. Install via "
|
|
110
|
-
"`npx @event4u/
|
|
110
|
+
"`npx @event4u/agent-config init` or `npm install -g @event4u/agent-config`."
|
|
111
111
|
)
|
|
112
112
|
return project_root # unreachable
|
|
113
113
|
|
|
@@ -1106,6 +1106,326 @@ def ensure_copilot_bridge(project_root: Path, force: bool) -> None:
|
|
|
1106
1106
|
success(".github/plugin/marketplace.json created")
|
|
1107
1107
|
|
|
1108
1108
|
|
|
1109
|
+
# Roo Code (https://docs.roocode.com/) is a Cline-derived VS Code extension
|
|
1110
|
+
# that auto-discovers `.roo/rules/*.md` as system-level instructions per
|
|
1111
|
+
# project. No hook protocol is exposed yet (2026-05), so the bridge is a
|
|
1112
|
+
# minimal marker file pointing the user at the canonical rule source. Phase
|
|
1113
|
+
# 2.0 validation gate — keep imperative and minimal; revisit when Roo Code
|
|
1114
|
+
# ships a programmatic hook surface.
|
|
1115
|
+
ROOCODE_MARKER = """# Agent Config bridge
|
|
1116
|
+
|
|
1117
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1118
|
+
|
|
1119
|
+
Roo Code reads `.roo/rules/*.md` as system-level instructions. The
|
|
1120
|
+
canonical rule and skill source lives under `.augment/` (Augment
|
|
1121
|
+
portability mirror — see `AGENTS.md` for orientation).
|
|
1122
|
+
|
|
1123
|
+
Run `./agent-config --help` for available commands.
|
|
1124
|
+
"""
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def ensure_roocode_bridge(project_root: Path, force: bool) -> None:
|
|
1128
|
+
"""Deploy `.roo/rules/agent-config.md` (project scope) marker.
|
|
1129
|
+
|
|
1130
|
+
Roo Code auto-discovers `.roo/rules/*.md` as system instructions — no
|
|
1131
|
+
hook protocol exposed yet. Bridge is intentionally minimal: a single
|
|
1132
|
+
marker file pointing developers at the canonical rule source. Phase
|
|
1133
|
+
2.0 validation gate (road-to-global-first-install § 2.0).
|
|
1134
|
+
"""
|
|
1135
|
+
target = project_root / ".roo" / "rules" / "agent-config.md"
|
|
1136
|
+
|
|
1137
|
+
if target.exists() and not force:
|
|
1138
|
+
skip(".roo/rules/agent-config.md already exists")
|
|
1139
|
+
return
|
|
1140
|
+
|
|
1141
|
+
write_file(target, ROOCODE_MARKER)
|
|
1142
|
+
success(".roo/rules/agent-config.md created")
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# Claude Desktop (https://claude.ai/download) reads config from
|
|
1146
|
+
# `~/Library/Application Support/Claude/` on macOS — no project-local
|
|
1147
|
+
# discovery. The project bridge is informational only: a marker file that
|
|
1148
|
+
# documents the link and tells humans where the canonical rules live.
|
|
1149
|
+
# Phase 2.3 will formalize this as scope=global-only via SCOPE_SUPPORT.
|
|
1150
|
+
CLAUDE_DESKTOP_MARKER = """# Agent Config bridge — Claude Desktop
|
|
1151
|
+
|
|
1152
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1153
|
+
|
|
1154
|
+
Claude Desktop is a **global-scope** tool — it reads config from
|
|
1155
|
+
`~/Library/Application Support/Claude/` (macOS) and does not
|
|
1156
|
+
auto-discover project files. This marker is informational only.
|
|
1157
|
+
|
|
1158
|
+
To wire Claude Desktop to this project's rules, run:
|
|
1159
|
+
`npx @event4u/agent-config init --ai claude-desktop --global`
|
|
1160
|
+
|
|
1161
|
+
Canonical rule and skill source: `.augment/` (see `AGENTS.md`).
|
|
1162
|
+
"""
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
def ensure_claude_desktop_bridge(project_root: Path, force: bool) -> None:
|
|
1166
|
+
"""Deploy `.claude-desktop/agent-config.md` informational marker.
|
|
1167
|
+
|
|
1168
|
+
Claude Desktop has no project-local discovery (global config only,
|
|
1169
|
+
macOS path `~/Library/Application Support/Claude/`). The marker is
|
|
1170
|
+
informational — Phase 2.3 will gate this bridge behind scope=global.
|
|
1171
|
+
"""
|
|
1172
|
+
target = project_root / ".claude-desktop" / "agent-config.md"
|
|
1173
|
+
|
|
1174
|
+
if target.exists() and not force:
|
|
1175
|
+
skip(".claude-desktop/agent-config.md already exists")
|
|
1176
|
+
return
|
|
1177
|
+
|
|
1178
|
+
write_file(target, CLAUDE_DESKTOP_MARKER)
|
|
1179
|
+
success(".claude-desktop/agent-config.md created")
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
# Aider (https://aider.chat) reads `.aider.conf.yml` per project and a
|
|
1183
|
+
# `CONVENTIONS.md` (or any path declared in `read:`) for system-level
|
|
1184
|
+
# instructions. The bridge drops a marker and documents the `read:`
|
|
1185
|
+
# wiring; we do not mutate `.aider.conf.yml` to avoid clobbering user
|
|
1186
|
+
# overrides. Phase 2.5 may upgrade this to a declarative emitter.
|
|
1187
|
+
AIDER_MARKER = """# Agent Config bridge — Aider
|
|
1188
|
+
|
|
1189
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1190
|
+
|
|
1191
|
+
Aider does not auto-discover this file. To activate it, add the
|
|
1192
|
+
following to `.aider.conf.yml` (create if missing):
|
|
1193
|
+
|
|
1194
|
+
```yaml
|
|
1195
|
+
read:
|
|
1196
|
+
- .aider/agent-config.md
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
Or pass `--read .aider/agent-config.md` on the command line.
|
|
1200
|
+
|
|
1201
|
+
Canonical rule and skill source: `.augment/` (see `AGENTS.md`).
|
|
1202
|
+
"""
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def ensure_aider_bridge(project_root: Path, force: bool) -> None:
|
|
1206
|
+
"""Deploy `.aider/agent-config.md` marker; do not touch `.aider.conf.yml`.
|
|
1207
|
+
|
|
1208
|
+
Aider reads `read:` entries from `.aider.conf.yml`. We intentionally
|
|
1209
|
+
avoid mutating that file — the marker documents the manual wiring so
|
|
1210
|
+
user overrides stay intact. Phase 2.5 may declarative-emit if needed.
|
|
1211
|
+
"""
|
|
1212
|
+
target = project_root / ".aider" / "agent-config.md"
|
|
1213
|
+
|
|
1214
|
+
if target.exists() and not force:
|
|
1215
|
+
skip(".aider/agent-config.md already exists")
|
|
1216
|
+
return
|
|
1217
|
+
|
|
1218
|
+
write_file(target, AIDER_MARKER)
|
|
1219
|
+
success(".aider/agent-config.md created")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
# OpenAI Codex CLI (https://github.com/openai/codex) auto-discovers
|
|
1223
|
+
# `AGENTS.md` at the project root as system instructions. The repo
|
|
1224
|
+
# already ships an `AGENTS.md` (Thin-Root contract), so the bridge is a
|
|
1225
|
+
# secondary marker confirming agent-config ownership — Codex will read
|
|
1226
|
+
# `AGENTS.md` directly regardless. Phase 2.5 may collapse if redundant.
|
|
1227
|
+
CODEX_MARKER = """# Agent Config bridge — Codex CLI
|
|
1228
|
+
|
|
1229
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1230
|
+
|
|
1231
|
+
Codex CLI auto-discovers `AGENTS.md` at the project root — that file
|
|
1232
|
+
is the canonical entry point. This marker is informational and tells
|
|
1233
|
+
developers where the rules and skills live.
|
|
1234
|
+
|
|
1235
|
+
Canonical rule and skill source: `.augment/` (see project `AGENTS.md`).
|
|
1236
|
+
"""
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def ensure_codex_bridge(project_root: Path, force: bool) -> None:
|
|
1240
|
+
"""Deploy `.codex/agent-config.md` informational marker.
|
|
1241
|
+
|
|
1242
|
+
Codex CLI reads `AGENTS.md` at project root directly — the marker
|
|
1243
|
+
is informational. Phase 2.5 may collapse if redundant with AGENTS.md.
|
|
1244
|
+
"""
|
|
1245
|
+
target = project_root / ".codex" / "agent-config.md"
|
|
1246
|
+
|
|
1247
|
+
if target.exists() and not force:
|
|
1248
|
+
skip(".codex/agent-config.md already exists")
|
|
1249
|
+
return
|
|
1250
|
+
|
|
1251
|
+
write_file(target, CODEX_MARKER)
|
|
1252
|
+
success(".codex/agent-config.md created")
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
# Continue.dev (https://continue.dev) auto-discovers `.continue/rules/*.md`
|
|
1256
|
+
# as system-level rules per project — same pattern as Roo Code. Bridge is
|
|
1257
|
+
# a single marker file in the rules directory; Continue will read it
|
|
1258
|
+
# directly on every session.
|
|
1259
|
+
CONTINUE_MARKER = """# Agent Config bridge — Continue.dev
|
|
1260
|
+
|
|
1261
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1262
|
+
|
|
1263
|
+
Continue.dev auto-discovers `.continue/rules/*.md` as system-level
|
|
1264
|
+
rules per session. The canonical rule and skill source lives under
|
|
1265
|
+
`.augment/` (Augment portability mirror — see `AGENTS.md` for
|
|
1266
|
+
orientation).
|
|
1267
|
+
"""
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
def ensure_continue_bridge(project_root: Path, force: bool) -> None:
|
|
1271
|
+
"""Deploy `.continue/rules/agent-config.md` (project scope) marker.
|
|
1272
|
+
|
|
1273
|
+
Continue.dev auto-discovers `.continue/rules/*.md` per project —
|
|
1274
|
+
mirror of the Roo Code pattern. Single marker file pointing
|
|
1275
|
+
developers at the canonical rule source under `.augment/`.
|
|
1276
|
+
"""
|
|
1277
|
+
target = project_root / ".continue" / "rules" / "agent-config.md"
|
|
1278
|
+
|
|
1279
|
+
if target.exists() and not force:
|
|
1280
|
+
skip(".continue/rules/agent-config.md already exists")
|
|
1281
|
+
return
|
|
1282
|
+
|
|
1283
|
+
write_file(target, CONTINUE_MARKER)
|
|
1284
|
+
success(".continue/rules/agent-config.md created")
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
# Kilo Code (https://kilocode.ai/) is a Cline-derived VS Code extension —
|
|
1288
|
+
# Roo Code's fork-cousin. Auto-discovers `.kilocode/rules/*.md` as
|
|
1289
|
+
# system-level instructions per project. Marker-only bridge in the same
|
|
1290
|
+
# spirit as Roo Code / Continue.dev.
|
|
1291
|
+
KILOCODE_MARKER = """# Agent Config bridge — Kilo Code
|
|
1292
|
+
|
|
1293
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1294
|
+
|
|
1295
|
+
Kilo Code auto-discovers `.kilocode/rules/*.md` as system-level rules
|
|
1296
|
+
per session. The canonical rule and skill source lives under
|
|
1297
|
+
`.augment/` (Augment portability mirror — see `AGENTS.md` for
|
|
1298
|
+
orientation).
|
|
1299
|
+
"""
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def ensure_kilocode_bridge(project_root: Path, force: bool) -> None:
|
|
1303
|
+
"""Deploy `.kilocode/rules/agent-config.md` (project scope) marker.
|
|
1304
|
+
|
|
1305
|
+
Kilo Code auto-discovers `.kilocode/rules/*.md` per project — Cline
|
|
1306
|
+
fork pattern mirroring Roo Code. Single marker file pointing
|
|
1307
|
+
developers at the canonical rule source under `.augment/`.
|
|
1308
|
+
"""
|
|
1309
|
+
target = project_root / ".kilocode" / "rules" / "agent-config.md"
|
|
1310
|
+
|
|
1311
|
+
if target.exists() and not force:
|
|
1312
|
+
skip(".kilocode/rules/agent-config.md already exists")
|
|
1313
|
+
return
|
|
1314
|
+
|
|
1315
|
+
write_file(target, KILOCODE_MARKER)
|
|
1316
|
+
success(".kilocode/rules/agent-config.md created")
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
# Zed (https://zed.dev) reads `.rules` at the project root as the
|
|
1320
|
+
# canonical system-instruction file. The bridge drops an informational
|
|
1321
|
+
# marker under `.zed/` documenting agent-config ownership — Zed itself
|
|
1322
|
+
# does not auto-discover `.zed/agent-config.md`. Phase 2.5 may upgrade
|
|
1323
|
+
# to a `.rules` emitter, but mirrors the AGENTS.md story until then.
|
|
1324
|
+
ZED_MARKER = """# Agent Config bridge — Zed
|
|
1325
|
+
|
|
1326
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1327
|
+
|
|
1328
|
+
Zed reads `.rules` at the project root as system-level instructions —
|
|
1329
|
+
that file is the canonical entry point. This marker is informational
|
|
1330
|
+
and tells developers where the rules and skills live.
|
|
1331
|
+
|
|
1332
|
+
To activate agent-config under Zed, point Zed's `.rules` at the
|
|
1333
|
+
canonical source (or symlink it):
|
|
1334
|
+
|
|
1335
|
+
```
|
|
1336
|
+
# Append to .rules at project root
|
|
1337
|
+
@.augment/AGENTS.md
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
Canonical rule and skill source: `.augment/` (see `AGENTS.md`).
|
|
1341
|
+
"""
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def ensure_zed_bridge(project_root: Path, force: bool) -> None:
|
|
1345
|
+
"""Deploy `.zed/agent-config.md` informational marker.
|
|
1346
|
+
|
|
1347
|
+
Zed reads `.rules` at the project root directly — the marker is
|
|
1348
|
+
informational and documents the wiring. Phase 2.5 may upgrade to a
|
|
1349
|
+
declarative `.rules` emitter.
|
|
1350
|
+
"""
|
|
1351
|
+
target = project_root / ".zed" / "agent-config.md"
|
|
1352
|
+
|
|
1353
|
+
if target.exists() and not force:
|
|
1354
|
+
skip(".zed/agent-config.md already exists")
|
|
1355
|
+
return
|
|
1356
|
+
|
|
1357
|
+
write_file(target, ZED_MARKER)
|
|
1358
|
+
success(".zed/agent-config.md created")
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
# JetBrains AI Assistant (https://www.jetbrains.com/ai/) reads guidelines
|
|
1362
|
+
# from project-level config files under `.idea/`. To avoid colliding with
|
|
1363
|
+
# the team-shared `.idea/` workspace, the bridge writes to
|
|
1364
|
+
# `.jetbrains/agent-config.md` (informational marker) and documents the
|
|
1365
|
+
# manual wiring step. Phase 2.5 may automate the `.idea/` write behind a
|
|
1366
|
+
# `--force` gate.
|
|
1367
|
+
JETBRAINS_MARKER = """# Agent Config bridge — JetBrains AI Assistant
|
|
1368
|
+
|
|
1369
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1370
|
+
|
|
1371
|
+
JetBrains AI Assistant reads custom prompts and guidelines from
|
|
1372
|
+
project-level config (`.idea/`) and user-scope settings. This marker
|
|
1373
|
+
is informational — to wire agent-config into JetBrains AI, point the
|
|
1374
|
+
assistant's custom-prompts path at `.augment/` or copy the relevant
|
|
1375
|
+
rules into your JetBrains profile.
|
|
1376
|
+
|
|
1377
|
+
Canonical rule and skill source: `.augment/` (see `AGENTS.md`).
|
|
1378
|
+
"""
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def ensure_jetbrains_bridge(project_root: Path, force: bool) -> None:
|
|
1382
|
+
"""Deploy `.jetbrains/agent-config.md` informational marker.
|
|
1383
|
+
|
|
1384
|
+
JetBrains AI reads config from `.idea/` and user-scope paths — we
|
|
1385
|
+
avoid mutating `.idea/` (team-shared workspace) and ship a marker
|
|
1386
|
+
documenting the manual wiring instead.
|
|
1387
|
+
"""
|
|
1388
|
+
target = project_root / ".jetbrains" / "agent-config.md"
|
|
1389
|
+
|
|
1390
|
+
if target.exists() and not force:
|
|
1391
|
+
skip(".jetbrains/agent-config.md already exists")
|
|
1392
|
+
return
|
|
1393
|
+
|
|
1394
|
+
write_file(target, JETBRAINS_MARKER)
|
|
1395
|
+
success(".jetbrains/agent-config.md created")
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
# Kiro (https://kiro.dev) is Amazon's agentic IDE. It auto-discovers
|
|
1399
|
+
# `.kiro/steering/*.md` as steering documents per project — same pattern
|
|
1400
|
+
# as Roo Code / Continue.dev / Kilo Code. Bridge is a single marker
|
|
1401
|
+
# under the steering directory.
|
|
1402
|
+
KIRO_MARKER = """# Agent Config bridge — Kiro
|
|
1403
|
+
|
|
1404
|
+
This file marks the project as an `event4u/agent-config` consumer.
|
|
1405
|
+
|
|
1406
|
+
Kiro auto-discovers `.kiro/steering/*.md` as steering documents per
|
|
1407
|
+
session. The canonical rule and skill source lives under `.augment/`
|
|
1408
|
+
(Augment portability mirror — see `AGENTS.md` for orientation).
|
|
1409
|
+
"""
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def ensure_kiro_bridge(project_root: Path, force: bool) -> None:
|
|
1413
|
+
"""Deploy `.kiro/steering/agent-config.md` (project scope) marker.
|
|
1414
|
+
|
|
1415
|
+
Kiro auto-discovers `.kiro/steering/*.md` per project — Cline /
|
|
1416
|
+
Continue.dev pattern. Single marker file pointing developers at
|
|
1417
|
+
the canonical rule source under `.augment/`.
|
|
1418
|
+
"""
|
|
1419
|
+
target = project_root / ".kiro" / "steering" / "agent-config.md"
|
|
1420
|
+
|
|
1421
|
+
if target.exists() and not force:
|
|
1422
|
+
skip(".kiro/steering/agent-config.md already exists")
|
|
1423
|
+
return
|
|
1424
|
+
|
|
1425
|
+
write_file(target, KIRO_MARKER)
|
|
1426
|
+
success(".kiro/steering/agent-config.md created")
|
|
1427
|
+
|
|
1428
|
+
|
|
1109
1429
|
# --- Post-install smoke test ---
|
|
1110
1430
|
|
|
1111
1431
|
# (platform, native event used for the dry-fire). Probe events are
|
|
@@ -1207,16 +1527,547 @@ def _smoke_test_hooks(project_root: Path, package_root: Path) -> int:
|
|
|
1207
1527
|
return 1 if failed else 0
|
|
1208
1528
|
|
|
1209
1529
|
|
|
1210
|
-
# --- Global user-level install —
|
|
1530
|
+
# --- Global user-level install — re-introduced under ADR-007 ---
|
|
1531
|
+
#
|
|
1532
|
+
# The pre-`5388de25` `--global` was an in-project symlink scheme driven by
|
|
1533
|
+
# `templates/global-install-manifest.yml`. ADR-007 (2026-05-12) rebuilds the
|
|
1534
|
+
# flag as a real-file user-scope install — writes to `~/.claude/`,
|
|
1535
|
+
# `~/.cursor/`, `~/.augment/`, etc. per the per-agent discovery matrix. No
|
|
1536
|
+
# symlinks; no manifest revival. This module currently exposes the dispatch
|
|
1537
|
+
# scaffold; concrete file writes are owned by later roadmap tasks
|
|
1538
|
+
# (export subcommand, lockfile lifecycle).
|
|
1539
|
+
|
|
1540
|
+
# Per-tool user-scope anchor paths per ADR-007 matrix. Listed in tool-ID
|
|
1541
|
+
# order matching `_VALID_TOOLS` so the scaffold output stays predictable.
|
|
1542
|
+
USER_SCOPE_PATHS = {
|
|
1543
|
+
"claude-code": "~/.claude/",
|
|
1544
|
+
"claude-desktop": "~/Library/Application Support/Claude/",
|
|
1545
|
+
"cursor": "~/.cursor/",
|
|
1546
|
+
"windsurf": "~/.codeium/windsurf/",
|
|
1547
|
+
"cline": "~/Documents/Cline/Rules/",
|
|
1548
|
+
"gemini-cli": "~/.gemini/",
|
|
1549
|
+
"copilot": "~/.copilot/",
|
|
1550
|
+
"augment": "~/.augment/",
|
|
1551
|
+
"aider": "~/.aider.conf.yml",
|
|
1552
|
+
"codex": "~/.codex/",
|
|
1553
|
+
"roocode": "~/.roo/",
|
|
1554
|
+
"continue": "~/.continue/",
|
|
1555
|
+
"kilocode": "~/.kilocode/",
|
|
1556
|
+
"zed": "~/.config/zed/",
|
|
1557
|
+
"jetbrains": "~/.config/JetBrains/",
|
|
1558
|
+
"kiro": "~/.kiro/",
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
# Per-tool scope support per ADR-007 matrix + Tier-1/2 verification.
|
|
1563
|
+
# Values: "both" · "project" · "global". Used by _validate_scope() to
|
|
1564
|
+
# reject explicit `--tools=X` selections that conflict with the chosen
|
|
1565
|
+
# scope (project default or `--global`). `--tools=all` silently filters
|
|
1566
|
+
# incompatible IDs so the default install path stays backward-compatible.
|
|
1211
1567
|
#
|
|
1212
|
-
#
|
|
1213
|
-
#
|
|
1214
|
-
#
|
|
1215
|
-
#
|
|
1216
|
-
#
|
|
1568
|
+
# Rationale:
|
|
1569
|
+
# - claude-desktop has no project discovery (informational marker only
|
|
1570
|
+
# in project trees); --project rejects it explicitly.
|
|
1571
|
+
# - jetbrains avoids mutating team-shared .idea/; --project marker is
|
|
1572
|
+
# informational only; canonical scope is global.
|
|
1573
|
+
# - roocode / kilocode auto-discover `.roo/rules/` and `.kilocode/rules/`
|
|
1574
|
+
# per project; no user-scope discovery convention; --global rejects.
|
|
1575
|
+
SCOPE_SUPPORT = {
|
|
1576
|
+
"claude-code": "both",
|
|
1577
|
+
"claude-desktop": "global",
|
|
1578
|
+
"cursor": "both",
|
|
1579
|
+
"windsurf": "both",
|
|
1580
|
+
"cline": "both",
|
|
1581
|
+
"gemini-cli": "both",
|
|
1582
|
+
"copilot": "both",
|
|
1583
|
+
"augment": "both",
|
|
1584
|
+
"aider": "both",
|
|
1585
|
+
"codex": "both",
|
|
1586
|
+
"roocode": "project",
|
|
1587
|
+
"continue": "both",
|
|
1588
|
+
"kilocode": "project",
|
|
1589
|
+
"zed": "both",
|
|
1590
|
+
"jetbrains": "global",
|
|
1591
|
+
"kiro": "both",
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
# Per-tool bridge marker paths used by the project-scope manifest (ADR-008
|
|
1596
|
+
# Phase 3.2). The value is the relative path inside the project tree (for
|
|
1597
|
+
# `scope=project`) or the absolute / `~`-prefixed user-scope path (for
|
|
1598
|
+
# `scope=global`). `validate` (Phase 3.4) checks that this file exists; the
|
|
1599
|
+
# manifest stores the path verbatim so a relocation of a bridge stays visible
|
|
1600
|
+
# in the lockfile.
|
|
1601
|
+
PROJECT_BRIDGE_MARKERS = {
|
|
1602
|
+
"claude-code": ".claude/settings.json",
|
|
1603
|
+
"claude-desktop": ".claude-desktop/agent-config.md",
|
|
1604
|
+
"cursor": ".cursor/hooks.json",
|
|
1605
|
+
"windsurf": ".windsurf/hooks.json",
|
|
1606
|
+
"cline": ".clinerules/hooks",
|
|
1607
|
+
"gemini-cli": ".gemini/settings.json",
|
|
1608
|
+
"copilot": ".github/plugin/marketplace.json",
|
|
1609
|
+
"augment": ".augment/settings.json",
|
|
1610
|
+
"aider": ".aider/agent-config.md",
|
|
1611
|
+
"codex": ".codex/agent-config.md",
|
|
1612
|
+
"roocode": ".roo/rules/agent-config.md",
|
|
1613
|
+
"continue": ".continue/rules/agent-config.md",
|
|
1614
|
+
"kilocode": ".kilocode/rules/agent-config.md",
|
|
1615
|
+
"zed": ".zed/agent-config.md",
|
|
1616
|
+
"jetbrains": ".jetbrains/agent-config.md",
|
|
1617
|
+
"kiro": ".kiro/steering/agent-config.md",
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
|
|
1621
|
+
def _bridge_marker(tool_id: str, scope: str) -> str:
|
|
1622
|
+
"""Return the canonical bridge-marker path for ``(tool_id, scope)``.
|
|
1623
|
+
|
|
1624
|
+
Project scope returns the repo-relative marker (e.g. ``.roo/rules/agent-
|
|
1625
|
+
config.md``). Global scope returns the user-scope anchor from
|
|
1626
|
+
:data:`USER_SCOPE_PATHS` (e.g. ``~/.claude/``). ADR-008 stores both as
|
|
1627
|
+
opaque strings; `validate` (Phase 3.4) handles the existence check.
|
|
1628
|
+
"""
|
|
1629
|
+
if scope == "global":
|
|
1630
|
+
return USER_SCOPE_PATHS.get(tool_id, "")
|
|
1631
|
+
return PROJECT_BRIDGE_MARKERS.get(tool_id, "")
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def _validate_scope(tools: set[str], scope: str, was_all: bool) -> set[str]:
|
|
1635
|
+
"""Validate tools against requested scope per SCOPE_SUPPORT.
|
|
1636
|
+
|
|
1637
|
+
`scope` is "project" or "global". When `was_all` is True (user passed
|
|
1638
|
+
`--tools=all` or omitted the flag), incompatible tools are silently
|
|
1639
|
+
filtered so the default install stays backward-compatible. Explicit
|
|
1640
|
+
tool lists hard-reject with a directive error per Phase 2.3.
|
|
1641
|
+
"""
|
|
1642
|
+
if scope not in ("project", "global"):
|
|
1643
|
+
fail(f"_validate_scope: unknown scope '{scope}'")
|
|
1644
|
+
incompatible = sorted(
|
|
1645
|
+
t for t in tools
|
|
1646
|
+
if SCOPE_SUPPORT.get(t, "both") not in ("both", scope)
|
|
1647
|
+
)
|
|
1648
|
+
if not incompatible:
|
|
1649
|
+
return tools
|
|
1650
|
+
if was_all:
|
|
1651
|
+
return {t for t in tools if t not in incompatible}
|
|
1652
|
+
hint = (
|
|
1653
|
+
"drop --global (project is the default scope)"
|
|
1654
|
+
if scope == "global" else "use --global"
|
|
1655
|
+
)
|
|
1656
|
+
fail(
|
|
1657
|
+
f"--tools: {', '.join(incompatible)} does not support "
|
|
1658
|
+
f"--{scope} scope ({hint})"
|
|
1659
|
+
)
|
|
1660
|
+
return tools # unreachable; fail() exits
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def _resolve_scope(
|
|
1664
|
+
opts: "argparse.Namespace",
|
|
1665
|
+
detected: str,
|
|
1666
|
+
detect_reason: str,
|
|
1667
|
+
custom_path: "Path | None",
|
|
1668
|
+
) -> str:
|
|
1669
|
+
"""Phase 1.4 — turn flags + detection into a concrete scope.
|
|
1670
|
+
|
|
1671
|
+
Precedence:
|
|
1672
|
+
|
|
1673
|
+
1. ``--scope=project|global`` — explicit override; ``--custom-path``
|
|
1674
|
+
may steer the project root.
|
|
1675
|
+
2. ``--scope=prompt`` — force the interactive chooser; ``--custom-path``
|
|
1676
|
+
pre-fills the Custom branch.
|
|
1677
|
+
3. ``--scope=auto`` — honor detection; trigger the prompt on
|
|
1678
|
+
``"prompt"``.
|
|
1679
|
+
4. ``--global`` — legacy alias for ``--scope=global``.
|
|
1680
|
+
5. No flag — backward-compatible default of ``project``, EXCEPT
|
|
1681
|
+
when detection returns ``"prompt"``, in which case the
|
|
1682
|
+
interactive chooser fires (matches ADR-007 D2 collision UX).
|
|
1683
|
+
|
|
1684
|
+
Returns ``"project"`` or ``"global"``. Never returns ``"prompt"`` —
|
|
1685
|
+
that intermediate state is resolved here. Aborts via ``fail()`` if
|
|
1686
|
+
a prompt is required but stdin is not a TTY.
|
|
1687
|
+
"""
|
|
1688
|
+
# Explicit --scope wins.
|
|
1689
|
+
if opts.scope == "project":
|
|
1690
|
+
return "project"
|
|
1691
|
+
if opts.scope == "global":
|
|
1692
|
+
return "global"
|
|
1693
|
+
if opts.scope == "prompt":
|
|
1694
|
+
return _run_scope_prompt(opts, detect_reason or "forced by --scope=prompt", custom_path)
|
|
1695
|
+
if opts.scope == "auto":
|
|
1696
|
+
if detected == "prompt":
|
|
1697
|
+
return _run_scope_prompt(opts, detect_reason, custom_path)
|
|
1698
|
+
if not QUIET:
|
|
1699
|
+
info(f"Scope: {detected} (auto-detected; {detect_reason})")
|
|
1700
|
+
return detected
|
|
1701
|
+
|
|
1702
|
+
# --global legacy alias.
|
|
1703
|
+
if opts.global_install:
|
|
1704
|
+
return "global"
|
|
1705
|
+
|
|
1706
|
+
# No flag — legacy default with collision auto-prompt.
|
|
1707
|
+
if detected == "prompt":
|
|
1708
|
+
return _run_scope_prompt(opts, detect_reason, custom_path)
|
|
1709
|
+
if not QUIET:
|
|
1710
|
+
info(f"Scope detection: {detected} ({detect_reason}). Using project default for backward compatibility; pass --scope=auto to honor detection.")
|
|
1711
|
+
return "project"
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _run_scope_prompt(
|
|
1715
|
+
opts: "argparse.Namespace",
|
|
1716
|
+
reason: str,
|
|
1717
|
+
custom_path: "Path | None",
|
|
1718
|
+
) -> str:
|
|
1719
|
+
"""Drive the interactive scope chooser, mutating ``opts.custom_path``
|
|
1720
|
+
when the user picks the Custom branch.
|
|
1721
|
+
|
|
1722
|
+
Fails fast (no prompt) when stdin is not a TTY and ``--custom-path``
|
|
1723
|
+
was not pre-supplied — CI callers must use ``--scope=project|global``.
|
|
1724
|
+
"""
|
|
1725
|
+
if not sys.stdin.isatty() and custom_path is None:
|
|
1726
|
+
fail(
|
|
1727
|
+
"Ambiguous install scope detected and stdin is not a TTY. "
|
|
1728
|
+
"Pass --scope=project|global (or --custom-path=<dir>) to override."
|
|
1729
|
+
)
|
|
1730
|
+
choice = prompt_scope_choice(reason)
|
|
1731
|
+
if choice == "project":
|
|
1732
|
+
return "project"
|
|
1733
|
+
if choice == "global":
|
|
1734
|
+
return "global"
|
|
1735
|
+
# SCOPE_CUSTOM — resolve a destination path.
|
|
1736
|
+
if custom_path is None:
|
|
1737
|
+
try:
|
|
1738
|
+
raw = _read_line("Custom destination path: ")
|
|
1739
|
+
except EOFError:
|
|
1740
|
+
fail("Custom-path prompt aborted (EOF on stdin)")
|
|
1741
|
+
if not raw:
|
|
1742
|
+
fail("Custom-path prompt requires a non-empty path")
|
|
1743
|
+
custom_path = Path(raw).expanduser().resolve()
|
|
1744
|
+
opts.custom_path = str(custom_path)
|
|
1745
|
+
if not QUIET:
|
|
1746
|
+
info(f"Custom destination: {custom_path}")
|
|
1747
|
+
return "project"
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
# Manifest files used by multi-signal scope detection (Phase 1.3). Listed
|
|
1751
|
+
# in the order they are most commonly the canonical project signal; the
|
|
1752
|
+
# detector short-circuits on the first hit. `.git/` is intentionally
|
|
1753
|
+
# absent — monorepos, dotfile-git repos and Hg/SVN workspaces all break
|
|
1754
|
+
# that signal (ADR-007 D2).
|
|
1755
|
+
SCOPE_DETECT_MANIFESTS = (
|
|
1756
|
+
"package.json", "composer.json", "pyproject.toml",
|
|
1757
|
+
"Cargo.toml", "go.mod", "Gemfile",
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
# Project-local AI-tool config that, if present alongside a manifest,
|
|
1761
|
+
# triggers the ambiguity prompt. Directories first, then well-known
|
|
1762
|
+
# top-level files. Conservative on purpose: false negatives (skip prompt,
|
|
1763
|
+
# install global) are recoverable via `--project`; false positives (prompt
|
|
1764
|
+
# in an empty dir) are a UX paper-cut.
|
|
1765
|
+
SCOPE_DETECT_AI_DIRS = (
|
|
1766
|
+
".claude", ".cursor", ".windsurf", ".augment",
|
|
1767
|
+
".clinerules", ".copilot", ".gemini", ".codex",
|
|
1768
|
+
".aider", ".continue", ".roo", ".kilocode",
|
|
1769
|
+
)
|
|
1770
|
+
SCOPE_DETECT_AI_FILES = (
|
|
1771
|
+
"CLAUDE.md", "AGENTS.md", "GEMINI.md",
|
|
1772
|
+
".windsurfrules", ".aider.conf.yml",
|
|
1773
|
+
)
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def detect_scope(cwd: Path) -> tuple[str, str]:
|
|
1777
|
+
"""Multi-signal scope detection per ADR-007 D2 / Phase 1.3.
|
|
1778
|
+
|
|
1779
|
+
Returns ``(scope, reason)`` where ``scope`` is one of:
|
|
1780
|
+
|
|
1781
|
+
* ``"project"`` — install into ``cwd`` (current behaviour preserved).
|
|
1782
|
+
Triggered by an existing ``.agent-settings.yml`` in ``cwd``.
|
|
1783
|
+
* ``"prompt"`` — caller MUST resolve the ambiguity (interactive
|
|
1784
|
+
prompt in 1.4 / ``--scope=<x>`` override for CI). Triggered by
|
|
1785
|
+
a manifest file (``package.json`` / ``composer.json`` / etc.)
|
|
1786
|
+
combined with at least one project-local AI-tool config marker.
|
|
1787
|
+
* ``"global"`` — install to user-scope paths. Default when no other
|
|
1788
|
+
signal fires (including ``cwd == ~``, empty dir, dotfile-git).
|
|
1789
|
+
|
|
1790
|
+
``.git/`` is explicitly NOT a signal — monorepos, dotfile managers,
|
|
1791
|
+
and non-Git workspaces all break it. Pure function; no side effects.
|
|
1792
|
+
"""
|
|
1793
|
+
if (cwd / SETTINGS_FILE).exists():
|
|
1794
|
+
return "project", f"existing {SETTINGS_FILE}"
|
|
1795
|
+
|
|
1796
|
+
has_manifest = next(
|
|
1797
|
+
(m for m in SCOPE_DETECT_MANIFESTS if (cwd / m).exists()),
|
|
1798
|
+
None,
|
|
1799
|
+
)
|
|
1800
|
+
has_ai_dir = next(
|
|
1801
|
+
(d for d in SCOPE_DETECT_AI_DIRS if (cwd / d).is_dir()),
|
|
1802
|
+
None,
|
|
1803
|
+
)
|
|
1804
|
+
has_ai_file = next(
|
|
1805
|
+
(f for f in SCOPE_DETECT_AI_FILES if (cwd / f).exists()),
|
|
1806
|
+
None,
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
if has_manifest and (has_ai_dir or has_ai_file):
|
|
1810
|
+
marker = has_ai_dir or has_ai_file
|
|
1811
|
+
return "prompt", f"manifest ({has_manifest}) + AI-tool config ({marker})"
|
|
1812
|
+
|
|
1813
|
+
return "global", "no project-scope signals"
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
# --- Interactive prompts (Phase 1.4) ---
|
|
1817
|
+
|
|
1818
|
+
# Sentinel returned by `prompt_scope_choice` for the "Custom path" branch.
|
|
1819
|
+
# The caller must follow up by reading a path (CLI: `--custom-path`; TTY:
|
|
1820
|
+
# a second prompt line). Kept as a constant so call sites can match on
|
|
1821
|
+
# identity rather than the string literal.
|
|
1822
|
+
SCOPE_CUSTOM = "custom"
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def _read_line(prompt_text: str) -> str:
|
|
1826
|
+
"""Thin wrapper over `input()` so tests can monkey-patch a single point.
|
|
1827
|
+
|
|
1828
|
+
Returns the user's stripped reply. Raises ``EOFError`` on Ctrl-D so
|
|
1829
|
+
callers can fail-fast rather than loop on closed stdin.
|
|
1830
|
+
"""
|
|
1831
|
+
return input(prompt_text).strip()
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def prompt_scope_choice(reason: str) -> str:
|
|
1835
|
+
"""Interactive 3-option scope chooser per ADR-007 D2 / Phase 1.4.
|
|
1836
|
+
|
|
1837
|
+
Returns one of ``"project"``, ``"global"``, ``SCOPE_CUSTOM``. The
|
|
1838
|
+
caller is responsible for resolving ``SCOPE_CUSTOM`` to an actual
|
|
1839
|
+
path (e.g. by reading ``--custom-path`` or a follow-up prompt).
|
|
1840
|
+
|
|
1841
|
+
Loops on invalid input; aborts the install via ``fail()`` on EOF
|
|
1842
|
+
(Ctrl-D) or three consecutive invalid replies so a stuck CI run
|
|
1843
|
+
cannot hang the parent process.
|
|
1844
|
+
"""
|
|
1845
|
+
print()
|
|
1846
|
+
info(f"Ambiguous install scope: {reason}.")
|
|
1847
|
+
info("Choose where to install:")
|
|
1848
|
+
print(" 1) Project — install into the current directory")
|
|
1849
|
+
print(" 2) User — install into ~/ (recommended; one install per machine)")
|
|
1850
|
+
print(" 3) Custom — specify an explicit destination path")
|
|
1851
|
+
print()
|
|
1852
|
+
attempts = 0
|
|
1853
|
+
while attempts < 3:
|
|
1854
|
+
try:
|
|
1855
|
+
reply = _read_line("Choose [1/2/3]: ")
|
|
1856
|
+
except EOFError:
|
|
1857
|
+
fail("Scope prompt aborted (EOF on stdin); pass --scope=project|global to override")
|
|
1858
|
+
if reply in ("1", "project", "p"):
|
|
1859
|
+
return "project"
|
|
1860
|
+
if reply in ("2", "global", "user", "u", "g"):
|
|
1861
|
+
return "global"
|
|
1862
|
+
if reply in ("3", "custom", "c"):
|
|
1863
|
+
return SCOPE_CUSTOM
|
|
1864
|
+
attempts += 1
|
|
1865
|
+
warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
|
|
1866
|
+
fail("Scope prompt aborted (3 invalid replies); pass --scope=project|global to override")
|
|
1867
|
+
return "project" # unreachable; fail() exits
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def prompt_collision_choice(path: Path) -> str:
|
|
1871
|
+
"""Hard-Floor 3-option prompt for an existing user-scope config file.
|
|
1872
|
+
|
|
1873
|
+
Returns one of ``"merge"``, ``"backup"``, ``"abort"``. Used by future
|
|
1874
|
+
write code paths (1.5 export, 1.6 lockfile) before clobbering an
|
|
1875
|
+
existing ``~/.claude/CLAUDE.md`` / ``~/.codex/AGENTS.md`` / etc. The
|
|
1876
|
+
helper itself does not touch the filesystem; the caller owns the
|
|
1877
|
+
merge / rename-to-`.bak.<ts>` / exit-1 action.
|
|
1878
|
+
"""
|
|
1879
|
+
print()
|
|
1880
|
+
warn(f"Existing file at {path}")
|
|
1881
|
+
info("Choose how to handle the collision:")
|
|
1882
|
+
print(" 1) Merge — append our content, preserve theirs")
|
|
1883
|
+
print(" 2) Backup and replace — rename existing to .bak.<ts>, write fresh")
|
|
1884
|
+
print(" 3) Abort — leave the file untouched, exit non-zero")
|
|
1885
|
+
print()
|
|
1886
|
+
attempts = 0
|
|
1887
|
+
while attempts < 3:
|
|
1888
|
+
try:
|
|
1889
|
+
reply = _read_line("Choose [1/2/3]: ")
|
|
1890
|
+
except EOFError:
|
|
1891
|
+
fail(f"Collision prompt aborted (EOF on stdin) for {path}")
|
|
1892
|
+
if reply in ("1", "merge", "m"):
|
|
1893
|
+
return "merge"
|
|
1894
|
+
if reply in ("2", "backup", "b"):
|
|
1895
|
+
return "backup"
|
|
1896
|
+
if reply in ("3", "abort", "a"):
|
|
1897
|
+
return "abort"
|
|
1898
|
+
attempts += 1
|
|
1899
|
+
warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
|
|
1900
|
+
fail(f"Collision prompt aborted (3 invalid replies) for {path}")
|
|
1901
|
+
return "abort" # unreachable
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _load_installed_lock_module():
|
|
1905
|
+
"""Lazy-import ``scripts._lib.installed_lock`` regardless of load mode.
|
|
1906
|
+
|
|
1907
|
+
``install.py`` runs both as a top-level script (``python3 scripts/install.py``)
|
|
1908
|
+
and as ``scripts.install`` (via ``from scripts.install import …``). The
|
|
1909
|
+
repo root has to be on ``sys.path`` for the package-qualified import to
|
|
1910
|
+
resolve in the script case.
|
|
1911
|
+
"""
|
|
1912
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
1913
|
+
if pkg_root not in sys.path:
|
|
1914
|
+
sys.path.insert(0, pkg_root)
|
|
1915
|
+
from scripts._lib import installed_lock # noqa: WPS433 — lazy by design
|
|
1916
|
+
return installed_lock
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def _load_installed_tools_module():
|
|
1920
|
+
"""Lazy-import ``scripts._lib.installed_tools`` (ADR-008 manifest)."""
|
|
1921
|
+
pkg_root = str(Path(__file__).resolve().parents[1])
|
|
1922
|
+
if pkg_root not in sys.path:
|
|
1923
|
+
sys.path.insert(0, pkg_root)
|
|
1924
|
+
from scripts._lib import installed_tools # noqa: WPS433 — lazy by design
|
|
1925
|
+
return installed_tools
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _update_installed_tools_manifest(
|
|
1929
|
+
project_root: Path,
|
|
1930
|
+
tools: set[str],
|
|
1931
|
+
scope: str,
|
|
1932
|
+
force: bool,
|
|
1933
|
+
) -> int:
|
|
1934
|
+
"""Append / refresh project-scope manifest entries (ADR-008 Phase 3.2).
|
|
1935
|
+
|
|
1936
|
+
Called after the bridge-write phase succeeds. The manifest lives at
|
|
1937
|
+
``<project_root>/agents/installed-tools.lock`` and tracks which AI tools
|
|
1938
|
+
this project expects, separate from ``.agent-project-settings.yml``
|
|
1939
|
+
(behaviour). Idempotent on (name, scope) match; refuses scope changes
|
|
1940
|
+
without ``--force`` per ADR-008 § Lifecycle.
|
|
1941
|
+
|
|
1942
|
+
Returns 0 on success, 1 on refusal (scope mismatch without ``--force``).
|
|
1943
|
+
"""
|
|
1944
|
+
tools_mod = _load_installed_tools_module()
|
|
1945
|
+
target = tools_mod.manifest_path(project_root)
|
|
1946
|
+
existing = tools_mod.read_manifest(target) or {}
|
|
1947
|
+
entries = list(existing.get("tools", []))
|
|
1948
|
+
|
|
1949
|
+
lock_mod = _load_installed_lock_module()
|
|
1950
|
+
version = lock_mod.current_package_version()
|
|
1951
|
+
|
|
1952
|
+
for tool_id in sorted(tools):
|
|
1953
|
+
marker = _bridge_marker(tool_id, scope)
|
|
1954
|
+
if not marker:
|
|
1955
|
+
# Substrate (vscode) or unknown — not tracked in the manifest.
|
|
1956
|
+
continue
|
|
1957
|
+
try:
|
|
1958
|
+
entries = tools_mod.upsert_tool(
|
|
1959
|
+
entries,
|
|
1960
|
+
name=tool_id,
|
|
1961
|
+
scope=scope,
|
|
1962
|
+
bridge_marker=marker,
|
|
1963
|
+
force=force,
|
|
1964
|
+
)
|
|
1965
|
+
except tools_mod.ScopeMismatchError as exc:
|
|
1966
|
+
if not QUIET:
|
|
1967
|
+
warn(str(exc))
|
|
1968
|
+
info(f" Manifest: {target}")
|
|
1969
|
+
info(" Override: re-run with `--force` to rewrite the entry")
|
|
1970
|
+
return 1
|
|
1971
|
+
|
|
1972
|
+
tools_mod.write_manifest(target, version, entries)
|
|
1973
|
+
if not QUIET:
|
|
1974
|
+
info(f"Manifest updated: {target.relative_to(project_root) if target.is_relative_to(project_root) else target}")
|
|
1975
|
+
return 0
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def install_global(
|
|
1979
|
+
tools: set[str],
|
|
1980
|
+
force: bool,
|
|
1981
|
+
project_root: Path | None = None,
|
|
1982
|
+
) -> int:
|
|
1983
|
+
"""User-scope install path (ADR-007 + Phase 1.6 lockfile lifecycle).
|
|
1984
|
+
|
|
1985
|
+
Reads ``~/.config/agent-config/installed.lock`` first. A recorded
|
|
1986
|
+
version that does not match the running package version refuses the
|
|
1987
|
+
install with a remediation hint unless ``--force`` is passed. On
|
|
1988
|
+
success the lockfile is rewritten atomically with the current
|
|
1989
|
+
package version + the union of previously-recorded and now-installed
|
|
1990
|
+
tool IDs. Concrete per-tool file writes still belong to later tasks
|
|
1991
|
+
(export remains the documented escape for project-local content).
|
|
1992
|
+
|
|
1993
|
+
When ``project_root`` points at a project tree (detected by the
|
|
1994
|
+
presence of ``.agent-settings.yml``), the project-scope manifest at
|
|
1995
|
+
``agents/installed-tools.lock`` is also refreshed with ``scope=global``
|
|
1996
|
+
entries per ADR-008 Phase 3.2.
|
|
1997
|
+
"""
|
|
1998
|
+
lock_mod = _load_installed_lock_module()
|
|
1999
|
+
installed_version = lock_mod.current_package_version()
|
|
2000
|
+
lock_path = lock_mod.lockfile_path()
|
|
2001
|
+
ok, recorded = lock_mod.check_version(installed_version, path=lock_path)
|
|
2002
|
+
|
|
2003
|
+
if not ok and not force:
|
|
2004
|
+
if not QUIET:
|
|
2005
|
+
print()
|
|
2006
|
+
warn("Refusing global install: lockfile version mismatch.")
|
|
2007
|
+
info(f" Lockfile: {lock_path}")
|
|
2008
|
+
info(f" Recorded version: {recorded}")
|
|
2009
|
+
info(f" Current package: {installed_version}")
|
|
2010
|
+
info(" Fix: run `agent-config update`")
|
|
2011
|
+
info(" Override: re-run with `--force` (replaces the lockfile)")
|
|
2012
|
+
print()
|
|
2013
|
+
return 1
|
|
2014
|
+
|
|
2015
|
+
if not QUIET:
|
|
2016
|
+
print()
|
|
2017
|
+
info("Agent Config — Global (user-scope) install [ADR-007]")
|
|
2018
|
+
info("Planned per-tool anchor paths:")
|
|
2019
|
+
for tool_id in sorted(tools):
|
|
2020
|
+
anchor = USER_SCOPE_PATHS.get(tool_id)
|
|
2021
|
+
if anchor is None:
|
|
2022
|
+
continue
|
|
2023
|
+
print(f" {tool_id:<15} → {anchor}")
|
|
2024
|
+
|
|
2025
|
+
existing = lock_mod.read_lockfile(path=lock_path) or {}
|
|
2026
|
+
existing_tools = list(existing.get("tools", []))
|
|
2027
|
+
merged_tools = sorted(set(existing_tools) | set(tools))
|
|
2028
|
+
written = lock_mod.write_lockfile(installed_version, merged_tools, path=lock_path)
|
|
2029
|
+
|
|
2030
|
+
if not QUIET:
|
|
2031
|
+
print()
|
|
2032
|
+
info(f"Lockfile written: {written}")
|
|
2033
|
+
info(f" schema_version=1, agent_config_version={installed_version}")
|
|
2034
|
+
info(f" tools={','.join(merged_tools)}")
|
|
2035
|
+
|
|
2036
|
+
# Refresh the project-scope manifest when running inside a project tree
|
|
2037
|
+
# (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
|
|
2038
|
+
# manifest to write and the global lockfile alone is the source of truth.
|
|
2039
|
+
if project_root is not None and (project_root / SETTINGS_FILE).exists():
|
|
2040
|
+
rc = _update_installed_tools_manifest(project_root, tools, "global", force)
|
|
2041
|
+
if rc != 0:
|
|
2042
|
+
return rc
|
|
2043
|
+
|
|
2044
|
+
if not QUIET:
|
|
2045
|
+
print()
|
|
2046
|
+
success("Global install recorded.")
|
|
2047
|
+
print()
|
|
2048
|
+
return 0
|
|
1217
2049
|
|
|
1218
2050
|
# --- Argument parsing ---
|
|
1219
2051
|
|
|
2052
|
+
def _merge_tools_aliases(tools: str | None, ai: str | None) -> str:
|
|
2053
|
+
"""Merge --tools and --ai into a single comma-separated value.
|
|
2054
|
+
|
|
2055
|
+
`--ai` is an alias for `--tools` (Phase 2.4 of the global-first
|
|
2056
|
+
roadmap). Both accepted; when both are passed the comma-separated
|
|
2057
|
+
values are unioned (order-preserving, deduplicated). When neither
|
|
2058
|
+
is passed the default `all` keeps the backward-compatible behaviour.
|
|
2059
|
+
"""
|
|
2060
|
+
items: list[str] = []
|
|
2061
|
+
for raw in (tools, ai):
|
|
2062
|
+
if not raw:
|
|
2063
|
+
continue
|
|
2064
|
+
for piece in raw.split(","):
|
|
2065
|
+
stripped = piece.strip()
|
|
2066
|
+
if stripped and stripped not in items:
|
|
2067
|
+
items.append(stripped)
|
|
2068
|
+
return ",".join(items) if items else "all"
|
|
2069
|
+
|
|
2070
|
+
|
|
1220
2071
|
def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
1221
2072
|
parser = argparse.ArgumentParser(
|
|
1222
2073
|
prog="install.py",
|
|
@@ -1260,19 +2111,64 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
1260
2111
|
parser.add_argument("--quiet", action="store_true", help="suppress info/success output (warnings/errors still shown)")
|
|
1261
2112
|
parser.add_argument(
|
|
1262
2113
|
"--tools",
|
|
1263
|
-
default=
|
|
2114
|
+
default=None,
|
|
1264
2115
|
help=(
|
|
1265
2116
|
"comma-separated tool IDs to install bridges for "
|
|
1266
|
-
"(claude-code,cursor,windsurf,cline,gemini-cli,
|
|
2117
|
+
"(claude-code,claude-desktop,cursor,windsurf,cline,gemini-cli,"
|
|
2118
|
+
"copilot,augment,aider,codex,roocode,continue,kilocode,zed,"
|
|
2119
|
+
"jetbrains,kiro,all). "
|
|
1267
2120
|
"Default: all (backward-compatible)."
|
|
1268
2121
|
),
|
|
1269
2122
|
)
|
|
2123
|
+
parser.add_argument(
|
|
2124
|
+
"--ai",
|
|
2125
|
+
default=None,
|
|
2126
|
+
help=(
|
|
2127
|
+
"alias for --tools (same IDs accepted). When both are passed "
|
|
2128
|
+
"the comma-separated values are unioned. Default: all."
|
|
2129
|
+
),
|
|
2130
|
+
)
|
|
1270
2131
|
parser.add_argument(
|
|
1271
2132
|
"--no-smoke",
|
|
1272
2133
|
action="store_true",
|
|
1273
2134
|
help="skip the post-install hook smoke test (default: dry-fire dispatch:hook against every installed bridge)",
|
|
1274
2135
|
)
|
|
1275
|
-
|
|
2136
|
+
parser.add_argument(
|
|
2137
|
+
"--global",
|
|
2138
|
+
dest="global_install",
|
|
2139
|
+
action="store_true",
|
|
2140
|
+
help="install to user-scope paths (~/.claude/, ~/.cursor/, …) per ADR-007 instead of project-locally",
|
|
2141
|
+
)
|
|
2142
|
+
parser.add_argument(
|
|
2143
|
+
"--scope",
|
|
2144
|
+
choices=("project", "global", "prompt", "auto"),
|
|
2145
|
+
default=None,
|
|
2146
|
+
help=(
|
|
2147
|
+
"force install scope (overrides --global and detection): "
|
|
2148
|
+
"project = install into cwd; global = install into user-scope paths; "
|
|
2149
|
+
"prompt = force the interactive 3-option chooser; "
|
|
2150
|
+
"auto = honor detect_scope() output. Default: legacy "
|
|
2151
|
+
"(project unless --global, with auto-prompt on collision detection)."
|
|
2152
|
+
),
|
|
2153
|
+
)
|
|
2154
|
+
parser.add_argument(
|
|
2155
|
+
"--custom-path",
|
|
2156
|
+
default=None,
|
|
2157
|
+
help=(
|
|
2158
|
+
"destination root for --scope=project when not the cwd "
|
|
2159
|
+
"(used by the 'Custom' branch of the scope chooser; ignored "
|
|
2160
|
+
"for --scope=global)."
|
|
2161
|
+
),
|
|
2162
|
+
)
|
|
2163
|
+
opts = parser.parse_args(argv)
|
|
2164
|
+
opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
|
|
2165
|
+
if opts.scope == "global" and opts.custom_path:
|
|
2166
|
+
fail("--custom-path is incompatible with --scope=global")
|
|
2167
|
+
if opts.global_install and opts.custom_path:
|
|
2168
|
+
fail("--custom-path is incompatible with --global")
|
|
2169
|
+
if opts.scope is not None and opts.global_install and opts.scope != "global":
|
|
2170
|
+
fail(f"--scope={opts.scope} conflicts with --global; pick one")
|
|
2171
|
+
return opts
|
|
1276
2172
|
|
|
1277
2173
|
|
|
1278
2174
|
# Mapping of --tools IDs accepted by install.py. Mirrors VALID_TOOLS in
|
|
@@ -1280,7 +2176,8 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
1280
2176
|
# bridges (vscode, augment) always run.
|
|
1281
2177
|
_VALID_TOOLS = {
|
|
1282
2178
|
"claude-code", "claude-desktop", "cursor", "windsurf", "cline",
|
|
1283
|
-
"gemini-cli", "copilot", "augment", "aider", "codex", "
|
|
2179
|
+
"gemini-cli", "copilot", "augment", "aider", "codex", "roocode",
|
|
2180
|
+
"continue", "kilocode", "zed", "jetbrains", "kiro", "all",
|
|
1284
2181
|
}
|
|
1285
2182
|
|
|
1286
2183
|
|
|
@@ -1303,6 +2200,18 @@ def _parse_tools(raw: str) -> set[str]:
|
|
|
1303
2200
|
return set(items)
|
|
1304
2201
|
|
|
1305
2202
|
|
|
2203
|
+
def _tools_was_all(raw: str) -> bool:
|
|
2204
|
+
"""True when the raw --tools value is the implicit/explicit `all` set.
|
|
2205
|
+
|
|
2206
|
+
Used by _validate_scope() to decide between silent-filter (default
|
|
2207
|
+
install backward-compatible) and hard-reject (explicit list).
|
|
2208
|
+
"""
|
|
2209
|
+
if not raw or not raw.strip():
|
|
2210
|
+
return False
|
|
2211
|
+
items = [s.strip() for s in raw.split(",") if s.strip()]
|
|
2212
|
+
return "all" in items
|
|
2213
|
+
|
|
2214
|
+
|
|
1306
2215
|
def _is_tool_enabled(tools: set[str], tool_id: str) -> bool:
|
|
1307
2216
|
return tool_id in tools
|
|
1308
2217
|
|
|
@@ -1318,7 +2227,30 @@ def main(argv: list[str]) -> int:
|
|
|
1318
2227
|
if opts.profile not in SUPPORTED_PROFILES:
|
|
1319
2228
|
fail(f"Unsupported profile: {opts.profile}. Supported: {', '.join(SUPPORTED_PROFILES)}")
|
|
1320
2229
|
|
|
1321
|
-
|
|
2230
|
+
# Multi-signal scope detection (Phase 1.3) + scope resolution
|
|
2231
|
+
# (Phase 1.4). Order of precedence (highest first):
|
|
2232
|
+
# 1. --scope=<x> — explicit user override (CI-friendly; auto = honor detection)
|
|
2233
|
+
# 2. --global — legacy alias for --scope=global
|
|
2234
|
+
# 3. detect_scope() == "prompt" → interactive 3-option chooser (TTY only)
|
|
2235
|
+
# 4. Legacy default → project (preserved for backward compatibility)
|
|
2236
|
+
detect_root = Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
2237
|
+
detected, detect_reason = detect_scope(detect_root)
|
|
2238
|
+
custom_path: Path | None = Path(opts.custom_path).resolve() if opts.custom_path else None
|
|
2239
|
+
scope = _resolve_scope(opts, detected, detect_reason, custom_path)
|
|
2240
|
+
|
|
2241
|
+
# Scope validation runs before filesystem / package detection so
|
|
2242
|
+
# --tools=X / --scope conflicts fail fast with a directive error
|
|
2243
|
+
# instead of partial-state side effects (Phase 2.3).
|
|
2244
|
+
parsed_tools = _parse_tools(opts.tools)
|
|
2245
|
+
tools_was_all = _tools_was_all(opts.tools)
|
|
2246
|
+
parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
|
|
2247
|
+
|
|
2248
|
+
if scope == "global":
|
|
2249
|
+
# Pass detect_root so the manifest refresh runs when --global is
|
|
2250
|
+
# invoked from within a project tree (ADR-008 Phase 3.2).
|
|
2251
|
+
return install_global(parsed_tools, opts.force, project_root=detect_root)
|
|
2252
|
+
|
|
2253
|
+
project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
|
|
1322
2254
|
is_first_run = not (project_root / SETTINGS_FILE).exists()
|
|
1323
2255
|
|
|
1324
2256
|
if opts.package:
|
|
@@ -1341,7 +2273,7 @@ def main(argv: list[str]) -> int:
|
|
|
1341
2273
|
|
|
1342
2274
|
ensure_agent_settings(project_root, package_root, opts.profile, opts.force)
|
|
1343
2275
|
|
|
1344
|
-
tools =
|
|
2276
|
+
tools = parsed_tools
|
|
1345
2277
|
|
|
1346
2278
|
if not opts.skip_bridges:
|
|
1347
2279
|
# Substrate bridges (always written; other tools symlink/depend on them).
|
|
@@ -1360,6 +2292,24 @@ def main(argv: list[str]) -> int:
|
|
|
1360
2292
|
ensure_gemini_bridge(project_root, opts.force)
|
|
1361
2293
|
if _is_tool_enabled(tools, "copilot"):
|
|
1362
2294
|
ensure_copilot_bridge(project_root, opts.force)
|
|
2295
|
+
if _is_tool_enabled(tools, "roocode"):
|
|
2296
|
+
ensure_roocode_bridge(project_root, opts.force)
|
|
2297
|
+
if _is_tool_enabled(tools, "claude-desktop"):
|
|
2298
|
+
ensure_claude_desktop_bridge(project_root, opts.force)
|
|
2299
|
+
if _is_tool_enabled(tools, "aider"):
|
|
2300
|
+
ensure_aider_bridge(project_root, opts.force)
|
|
2301
|
+
if _is_tool_enabled(tools, "codex"):
|
|
2302
|
+
ensure_codex_bridge(project_root, opts.force)
|
|
2303
|
+
if _is_tool_enabled(tools, "continue"):
|
|
2304
|
+
ensure_continue_bridge(project_root, opts.force)
|
|
2305
|
+
if _is_tool_enabled(tools, "kilocode"):
|
|
2306
|
+
ensure_kilocode_bridge(project_root, opts.force)
|
|
2307
|
+
if _is_tool_enabled(tools, "zed"):
|
|
2308
|
+
ensure_zed_bridge(project_root, opts.force)
|
|
2309
|
+
if _is_tool_enabled(tools, "jetbrains"):
|
|
2310
|
+
ensure_jetbrains_bridge(project_root, opts.force)
|
|
2311
|
+
if _is_tool_enabled(tools, "kiro"):
|
|
2312
|
+
ensure_kiro_bridge(project_root, opts.force)
|
|
1363
2313
|
|
|
1364
2314
|
if opts.augment_user_hooks:
|
|
1365
2315
|
ensure_augment_user_hooks(package_root, opts.force)
|
|
@@ -1382,6 +2332,17 @@ def main(argv: list[str]) -> int:
|
|
|
1382
2332
|
info("Smoke-testing installed hook bridges (dry-run)")
|
|
1383
2333
|
_smoke_test_hooks(project_root, package_root)
|
|
1384
2334
|
|
|
2335
|
+
# Refresh the project-scope installed-tools manifest (ADR-008 Phase 3.2).
|
|
2336
|
+
# Runs after bridges are on disk so the manifest only lists tools whose
|
|
2337
|
+
# markers actually exist. Skipped when `--skip-bridges` was used (the
|
|
2338
|
+
# caller is exercising the install plan, not committing to it).
|
|
2339
|
+
if not opts.skip_bridges:
|
|
2340
|
+
rc = _update_installed_tools_manifest(
|
|
2341
|
+
project_root, parsed_tools, "project", opts.force,
|
|
2342
|
+
)
|
|
2343
|
+
if rc != 0:
|
|
2344
|
+
return rc
|
|
2345
|
+
|
|
1385
2346
|
if not QUIET:
|
|
1386
2347
|
print()
|
|
1387
2348
|
success("Done.")
|
|
@@ -1394,7 +2355,7 @@ def main(argv: list[str]) -> int:
|
|
|
1394
2355
|
print()
|
|
1395
2356
|
print(" Next steps:")
|
|
1396
2357
|
print(" • Commit .agent-settings.yml and bridge files to your repo")
|
|
1397
|
-
print(" • New team members run `npx @event4u/
|
|
2358
|
+
print(" • New team members run `npx @event4u/agent-config init` — done")
|
|
1398
2359
|
print(" • Inspect hook coverage: ./agent-config hooks:status")
|
|
1399
2360
|
print(" • Full walkthrough: https://github.com/event4u-app/agent-config/blob/main/docs/getting-started.md")
|
|
1400
2361
|
print()
|