@event4u/agent-config 3.0.0 → 3.1.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/commands/install-via-agent.md +129 -0
- package/.agent-src/commands/video/from-script.md +1 -1
- package/.agent-src/commands/video.md +1 -1
- package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
- package/.agent-src/rules/caveman-speak.md +2 -2
- package/.agent-src/rules/context-hygiene.md +36 -0
- package/.agent-src/rules/engineering-safety-floor.md +102 -0
- package/.agent-src/rules/finance-safety-floor.md +114 -0
- package/.agent-src/rules/git-history-discipline.md +1 -1
- package/.agent-src/rules/no-cheap-questions.md +34 -32
- package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
- package/.agent-src/rules/strategy-safety-floor.md +114 -0
- package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
- package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
- package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
- package/.agent-src/skills/readme-writing/SKILL.md +52 -4
- package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
- package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
- package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
- package/.claude-plugin/marketplace.json +2 -1
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +233 -123
- package/README.md +165 -553
- package/config/agent-settings.template.yml +0 -7
- package/config/discovery/packs.yml +20 -0
- package/config/discovery/unassigned-artefacts.yml +2 -0
- package/config/gitignore-block.txt +19 -3
- package/dist/cli/commands/uiServe.js +13 -4
- package/dist/cli/commands/uiServe.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +7 -0
- package/dist/discovery/discovery-manifest.json +2107 -1409
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +9 -9
- package/dist/discovery/orphan-report.md +10 -0
- package/dist/discovery/packs.json +1002 -0
- package/dist/discovery/trust-report.md +26 -0
- package/dist/discovery/workspaces.json +705 -0
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/router.json +1623 -0
- package/dist/server/app.js +11 -3
- package/dist/server/app.js.map +1 -1
- package/dist/server/io/atomicMultiWrite.js +3 -1
- package/dist/server/io/atomicMultiWrite.js.map +1 -1
- package/dist/server/io/yamlIO.js +22 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/ping.js +8 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/routes/schema.js +2 -2
- package/dist/server/routes/schema.js.map +1 -1
- package/dist/server/routes/settings.js +104 -23
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/userMd.js +37 -27
- package/dist/server/routes/userMd.js.map +1 -1
- package/dist/server/routes/wizard.js +256 -20
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +0 -1
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/token.js +10 -3
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +28 -11
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/server/writeRoot.test.js +22 -4
- package/dist/server/writeRoot.test.js.map +1 -1
- package/dist/shared/userMd/formAdapter.js +29 -51
- package/dist/shared/userMd/formAdapter.js.map +1 -1
- package/dist/shared/userMd/schema.js +32 -104
- package/dist/shared/userMd/schema.js.map +1 -1
- package/dist/shared/userMd/utils.js +64 -50
- package/dist/shared/userMd/utils.js.map +1 -1
- package/dist/ui/assets/index-D-DY1ywI.js +35 -0
- package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/adrs/router/0001-three-tier-routing.md +5 -5
- package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
- package/docs/catalog.md +30 -26
- package/docs/contracts/CHANGELOG-conventions.md +1 -1
- package/docs/contracts/agent-user-schema.md +6 -9
- package/docs/contracts/consumer-bridge.md +79 -0
- package/docs/contracts/discovery-manifest.md +209 -0
- package/docs/contracts/discovery-manifest.schema.json +77 -4
- package/docs/contracts/explain-trace.schema.json +1 -1
- package/docs/contracts/file-ownership-matrix.json +197 -13
- package/docs/contracts/frontmatter-contract.md +140 -0
- package/docs/contracts/gui-wizard.md +223 -0
- package/docs/contracts/installer-agent-mode.md +137 -0
- package/docs/contracts/kernel-membership.md +1 -1
- package/docs/contracts/mcp-tool-inventory.md +9 -9
- package/docs/contracts/namespace.md +6 -6
- package/docs/contracts/provider-lifecycle.md +5 -5
- package/docs/contracts/rule-router.md +4 -4
- package/docs/contracts/settings-api.md +53 -6
- package/docs/contracts/smoke-contracts.md +3 -3
- package/docs/contracts/trust-and-safety.md +144 -0
- package/docs/customization.md +2 -2
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
- package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
- package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
- package/docs/decisions/ADR-016-installer-architecture.md +189 -0
- package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
- package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
- package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
- package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
- package/docs/decisions/ADR-021-deployment-shape.md +153 -0
- package/docs/decisions/INDEX.md +7 -0
- package/docs/deploy/connector-setup.md +129 -0
- package/docs/deploy/env-vars.md +70 -0
- package/docs/deploy/policy-cookbook.md +130 -0
- package/docs/deploy/quickstart.md +112 -0
- package/docs/distribution/public-install-smoke.md +68 -0
- package/docs/distribution/registries.md +55 -0
- package/docs/distribution/telemetry-privacy.md +128 -0
- package/docs/distribution/telemetry-schema.md +174 -0
- package/docs/featured-skills.md +95 -0
- package/docs/getting-started-by-role.md +19 -1
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
- package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
- package/docs/installation.md +27 -14
- package/docs/maintainers/dev-mode.md +105 -0
- package/docs/setup/per-ide/claude-desktop.md +3 -2
- package/docs/wizard.md +39 -4
- package/package.json +18 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +150 -2
- package/scripts/_cli/cmd_explain.py +2 -1
- package/scripts/_cli/cmd_migrate_to_global.py +415 -0
- package/scripts/_cli/cmd_settings_migrate.py +146 -0
- package/scripts/_cli/explain_last/route.py +2 -1
- package/scripts/_dispatch.bash +36 -3
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/agent_settings.py +4 -1
- package/scripts/_lib/agent_src.py +157 -0
- package/scripts/agent-config +17 -6
- package/scripts/audit_skill_descriptions.py +18 -6
- package/scripts/build_discovery_manifest.py +373 -17
- package/scripts/check_artefact_checksums.py +104 -0
- package/scripts/check_cluster_patterns.py +20 -4
- package/scripts/check_command_count_messaging.py +33 -14
- package/scripts/check_council_references.py +43 -4
- package/scripts/check_overlay_cascade_subdirs.py +7 -3
- package/scripts/check_references.py +5 -2
- package/scripts/check_reply_consistency.py +32 -9
- package/scripts/check_template_pin_drift.py +24 -7
- package/scripts/check_token_optimizer_freshness.py +18 -3
- package/scripts/compile_router.py +34 -2
- package/scripts/compress.py +162 -44
- package/scripts/config/presets.py +19 -1
- package/scripts/config/profiles.py +16 -1
- package/scripts/discovery_stats.py +70 -0
- package/scripts/expected_perms.json +47 -0
- package/scripts/generate_index.py +78 -46
- package/scripts/generate_ownership_matrix.py +98 -43
- package/scripts/generate_pack_manifests.py +183 -0
- package/scripts/install +18 -1
- package/scripts/install.py +934 -59
- package/scripts/install.sh +27 -9
- package/scripts/lint_agents_layout.py +93 -13
- package/scripts/lint_agents_md.py +1 -1
- package/scripts/lint_archived_skills.py +32 -16
- package/scripts/lint_bench_corpus.py +14 -2
- package/scripts/lint_command_tiers.py +15 -2
- package/scripts/lint_featured_skills.py +139 -0
- package/scripts/lint_framework_leakage.py +33 -6
- package/scripts/lint_global_paths.py +147 -0
- package/scripts/lint_orchestration_dsl.py +6 -3
- package/scripts/lint_pack_boundaries.py +147 -0
- package/scripts/lint_pack_first_win.py +103 -0
- package/scripts/lint_readme_jargon.py +131 -0
- package/scripts/lint_readme_size.py +33 -0
- package/scripts/lint_rule_interactions.py +23 -5
- package/scripts/lint_rule_tiers.py +12 -3
- package/scripts/lint_trust_coherence.py +212 -0
- package/scripts/measure_rule_budget.py +22 -4
- package/scripts/move_artefact.py +143 -0
- package/scripts/new_skill.py +148 -0
- package/scripts/plan_physical_move.py +353 -0
- package/scripts/refine_ticket_detect.py +30 -7
- package/scripts/release.py +22 -2
- package/scripts/schemas/command.schema.json +4 -0
- package/scripts/skill_linter.py +248 -118
- package/scripts/skill_trigger_eval.py +28 -8
- package/scripts/smoke/kernel.sh +1 -1
- package/scripts/smoke/router.sh +24 -5
- package/scripts/smoke/skills.sh +15 -7
- package/scripts/smoke_quickstart.py +11 -2
- package/scripts/snapshot_agent_outputs.py +144 -0
- package/scripts/update_counts.py +45 -17
- package/scripts/validate_decision_engine.py +9 -1
- package/scripts/validate_discovery_manifest.py +94 -0
- package/scripts/validate_frontmatter.py +39 -20
- package/scripts/verify_physical_move.py +185 -0
- package/templates/agent-user.md +0 -1
- package/templates/agent-user.yml +21 -0
- package/templates/minimal/agents-overrides-readme.md +46 -0
- package/templates/minimal/overrides-gitkeep +2 -0
- package/dist/ui/assets/index-BTRcKDlB.js +0 -39
- package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
- package/templates/minimal/agents-gitkeep +0 -2
package/scripts/install.py
CHANGED
|
@@ -34,6 +34,9 @@ import shlex
|
|
|
34
34
|
import shutil
|
|
35
35
|
import subprocess
|
|
36
36
|
import sys
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from datetime import datetime, timezone
|
|
37
40
|
from pathlib import Path
|
|
38
41
|
from typing import Any, Optional
|
|
39
42
|
|
|
@@ -1602,7 +1605,8 @@ def ensure_roocode_bridge(project_root: Path, force: bool) -> None:
|
|
|
1602
1605
|
# `~/Library/Application Support/Claude/` on macOS — no project-local
|
|
1603
1606
|
# discovery. The project bridge is informational only: a marker file that
|
|
1604
1607
|
# documents the link and tells humans where the canonical rules live.
|
|
1605
|
-
#
|
|
1608
|
+
# Formalized as scope=global-only via SCOPE_SUPPORT (Phase 3.1 of
|
|
1609
|
+
# road-to-global-only-install — consumer installs are global-only).
|
|
1606
1610
|
CLAUDE_DESKTOP_MARKER = """# Agent Config bridge — Claude Desktop
|
|
1607
1611
|
|
|
1608
1612
|
This file marks the project as an `event4u/agent-config` consumer.
|
|
@@ -2052,26 +2056,36 @@ USER_SCOPE_PATHS = {
|
|
|
2052
2056
|
}
|
|
2053
2057
|
|
|
2054
2058
|
|
|
2055
|
-
# Per-tool scope support per ADR-007 matrix +
|
|
2056
|
-
# Values: "both" · "project" · "global". Used by _validate_scope()
|
|
2057
|
-
# reject explicit `--tools=X` selections that conflict with the chosen
|
|
2058
|
-
# scope
|
|
2059
|
-
#
|
|
2059
|
+
# Per-tool scope support per ADR-007 matrix + ADR-020 consumer global-only
|
|
2060
|
+
# amendment. Values: "both" · "project" · "global". Used by _validate_scope()
|
|
2061
|
+
# to reject explicit `--tools=X` selections that conflict with the chosen
|
|
2062
|
+
# scope. `--tools=all` silently filters incompatible IDs so the default
|
|
2063
|
+
# install path stays backward-compatible.
|
|
2060
2064
|
#
|
|
2061
|
-
#
|
|
2062
|
-
#
|
|
2063
|
-
#
|
|
2064
|
-
#
|
|
2065
|
-
#
|
|
2066
|
-
#
|
|
2067
|
-
#
|
|
2065
|
+
# road-to-global-only-install § Phase 3.1 — consumer installs are
|
|
2066
|
+
# global-only. Every AI ID with a user-scope convention is pinned to
|
|
2067
|
+
# "global". Maintainers can still drive project-scope installs by
|
|
2068
|
+
# setting AGENT_CONFIG_DEV_MODE=1 — `_enforce_consumer_global_only`
|
|
2069
|
+
# gates the scope before validation, so the SCOPE_SUPPORT matrix is the
|
|
2070
|
+
# canonical declaration of "where this tool is allowed to write."
|
|
2071
|
+
#
|
|
2072
|
+
# Exception:
|
|
2073
|
+
# - copilot is "both" because GitHub Copilot has no user-scope
|
|
2074
|
+
# convention for instructions — `copilot-instructions.md` lives
|
|
2075
|
+
# in-repo by design. Project scope still requires
|
|
2076
|
+
# AGENT_CONFIG_DEV_MODE=1 (consumer-floor gate); the "both" value
|
|
2077
|
+
# keeps the gate at the env-flag layer rather than the matrix layer.
|
|
2068
2078
|
SCOPE_SUPPORT = {
|
|
2069
|
-
"claude-code": "
|
|
2079
|
+
"claude-code": "global",
|
|
2070
2080
|
"claude-desktop": "global",
|
|
2071
|
-
"cursor": "
|
|
2072
|
-
"windsurf": "
|
|
2073
|
-
"cline": "
|
|
2074
|
-
"gemini-cli": "
|
|
2081
|
+
"cursor": "global",
|
|
2082
|
+
"windsurf": "global",
|
|
2083
|
+
"cline": "global",
|
|
2084
|
+
"gemini-cli": "global",
|
|
2085
|
+
# GitHub Copilot ships `copilot-instructions.md` in-repo by design
|
|
2086
|
+
# — no user-scope convention exists. Project scope stays available
|
|
2087
|
+
# but is gated by AGENT_CONFIG_DEV_MODE=1 via the consumer-floor
|
|
2088
|
+
# check, not by the matrix.
|
|
2075
2089
|
"copilot": "both",
|
|
2076
2090
|
# `augment` is global-only by design: a single user-scope deploy to
|
|
2077
2091
|
# `~/.augment/` is the canonical surface. The package owner accepts
|
|
@@ -2081,17 +2095,14 @@ SCOPE_SUPPORT = {
|
|
|
2081
2095
|
# installs are rejected so the per-repo `.augment/` surface stays
|
|
2082
2096
|
# out of the install matrix entirely.
|
|
2083
2097
|
"augment": "global",
|
|
2084
|
-
"aider": "
|
|
2085
|
-
"codex": "
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
"
|
|
2090
|
-
"continue": "both",
|
|
2091
|
-
"kilocode": "both",
|
|
2092
|
-
"zed": "both",
|
|
2098
|
+
"aider": "global",
|
|
2099
|
+
"codex": "global",
|
|
2100
|
+
"roocode": "global",
|
|
2101
|
+
"continue": "global",
|
|
2102
|
+
"kilocode": "global",
|
|
2103
|
+
"zed": "global",
|
|
2093
2104
|
"jetbrains": "global",
|
|
2094
|
-
"kiro": "
|
|
2105
|
+
"kiro": "global",
|
|
2095
2106
|
# Phase 2.4 expansion — global-only for new anchors; project bridges
|
|
2096
2107
|
# are not yet implemented for these IDs.
|
|
2097
2108
|
"qoder": "global",
|
|
@@ -2248,6 +2259,15 @@ To remove this marker, delete this file.
|
|
|
2248
2259
|
#: package-owned, not Claude-owned, content.
|
|
2249
2260
|
_CLAUDE_DESKTOP_BUNDLES_SUBPATH = "claude-desktop/bundles"
|
|
2250
2261
|
|
|
2262
|
+
#: road-to-global-only-install § Phase 2.1 — canonical global path
|
|
2263
|
+
#: constants. Single source of truth for the user-scope settings file
|
|
2264
|
+
#: locations. Used by the settings reader (Python + TypeScript via
|
|
2265
|
+
#: docs/contracts/settings-api.md) to merge ``defaults < global <
|
|
2266
|
+
#: project-overrides``.
|
|
2267
|
+
GLOBAL_ROOT = Path.home() / ".event4u" / "agent-config"
|
|
2268
|
+
GLOBAL_USER_SETTINGS_PATH = GLOBAL_ROOT / ".agent-user.yml"
|
|
2269
|
+
GLOBAL_AGENT_SETTINGS_PATH = GLOBAL_ROOT / ".agent-settings.yml"
|
|
2270
|
+
|
|
2251
2271
|
|
|
2252
2272
|
def _bridge_marker(tool_id: str, scope: str) -> str:
|
|
2253
2273
|
"""Return the canonical bridge-marker path for ``(tool_id, scope)``.
|
|
@@ -2269,9 +2289,22 @@ def _validate_scope(tools: set[str], scope: str, was_all: bool) -> set[str]:
|
|
|
2269
2289
|
`--tools=all` or omitted the flag), incompatible tools are silently
|
|
2270
2290
|
filtered so the default install stays backward-compatible. Explicit
|
|
2271
2291
|
tool lists hard-reject with a directive error per Phase 2.3.
|
|
2292
|
+
|
|
2293
|
+
Maintainer dev mode (``AGENT_CONFIG_DEV_MODE=1``) bypasses the matrix
|
|
2294
|
+
filter entirely. Per ``docs/maintainers/dev-mode.md`` the flag
|
|
2295
|
+
"allows project-scope writes back into the repo tree" — that
|
|
2296
|
+
contract requires the full bridge surface (cursor / cline / windsurf
|
|
2297
|
+
/ gemini-cli / …) to remain reachable under ``--project`` so
|
|
2298
|
+
``task dev:install-global`` can dogfood every projection. The
|
|
2299
|
+
consumer-facing gate already runs upstream via
|
|
2300
|
+
``_enforce_consumer_global_only``; reaching this function with
|
|
2301
|
+
``scope == "project"`` means the dev gate already approved the
|
|
2302
|
+
write, so the matrix filter would be double-gating maintainer flows.
|
|
2272
2303
|
"""
|
|
2273
2304
|
if scope not in ("project", "global"):
|
|
2274
2305
|
fail(f"_validate_scope: unknown scope '{scope}'")
|
|
2306
|
+
if os.environ.get("AGENT_CONFIG_DEV_MODE") == "1":
|
|
2307
|
+
return tools
|
|
2275
2308
|
incompatible = sorted(
|
|
2276
2309
|
t for t in tools
|
|
2277
2310
|
if SCOPE_SUPPORT.get(t, "both") not in ("both", scope)
|
|
@@ -2291,6 +2324,125 @@ def _validate_scope(tools: set[str], scope: str, was_all: bool) -> set[str]:
|
|
|
2291
2324
|
return tools # unreachable; fail() exits
|
|
2292
2325
|
|
|
2293
2326
|
|
|
2327
|
+
def _enforce_consumer_global_only(scope: str) -> None:
|
|
2328
|
+
"""road-to-global-only-install § Phase 3.2 — gate the project scope.
|
|
2329
|
+
|
|
2330
|
+
Consumer installs ship global-only (ADR-020). The legacy project
|
|
2331
|
+
scope stays available for maintainers via ``AGENT_CONFIG_DEV_MODE=1``
|
|
2332
|
+
so the dogfood-on-this-repo loop keeps working. Anything else
|
|
2333
|
+
routing through the orchestrator with ``scope == "project"`` aborts
|
|
2334
|
+
with a directive error pointing at the maintainer doc.
|
|
2335
|
+
|
|
2336
|
+
Pure side-effect gate — separate from ``_resolve_scope`` so the
|
|
2337
|
+
unit-tested resolver stays a pure function of its inputs.
|
|
2338
|
+
"""
|
|
2339
|
+
if scope != "project":
|
|
2340
|
+
return
|
|
2341
|
+
if os.environ.get("AGENT_CONFIG_DEV_MODE") == "1":
|
|
2342
|
+
return
|
|
2343
|
+
fail(
|
|
2344
|
+
"--scope=project is reserved for maintainers (ADR-020 — consumer "
|
|
2345
|
+
"installs are global-only). Set AGENT_CONFIG_DEV_MODE=1 to opt in. "
|
|
2346
|
+
"See docs/maintainers/dev-mode.md."
|
|
2347
|
+
)
|
|
2348
|
+
|
|
2349
|
+
|
|
2350
|
+
# --- road-to-global-only-install § Phase 2.2 — three-layer settings reader ---
|
|
2351
|
+
#
|
|
2352
|
+
# Merge order (per ADR-020 / D9):
|
|
2353
|
+
#
|
|
2354
|
+
# defaults < global < project-overrides
|
|
2355
|
+
#
|
|
2356
|
+
# The defaults layer is the rendered template body in
|
|
2357
|
+
# ``config/agent-settings.template.yml``. The global layer is
|
|
2358
|
+
# ``~/.event4u/agent-config/.agent-settings.yml``. The project layer is
|
|
2359
|
+
# ``<project_root>/.agent-settings.yml`` — tolerated but no longer
|
|
2360
|
+
# required to exist. Any layer that is missing or unparseable falls back
|
|
2361
|
+
# to an empty dict so the merge stays total.
|
|
2362
|
+
#
|
|
2363
|
+
# The TypeScript wizard route ``GET /api/v1/wizard/settings`` mirrors the
|
|
2364
|
+
# same precedence (see :mod:`src.server.routes.wizard`) so the Python
|
|
2365
|
+
# installer and the Fastify server agree on what *the user's effective
|
|
2366
|
+
# settings* look like at any given moment.
|
|
2367
|
+
|
|
2368
|
+
def _load_yaml_doc(path: Path) -> dict:
|
|
2369
|
+
"""Load a YAML file as a dict; return ``{}`` on every recoverable error.
|
|
2370
|
+
|
|
2371
|
+
Used by the three-layer settings reader. Mirrors the defensive shape
|
|
2372
|
+
of :func:`scripts.config.profiles._load_yaml`: missing PyYAML, missing
|
|
2373
|
+
file, parse error, or non-dict root all collapse to an empty dict so
|
|
2374
|
+
callers can blindly :func:`deep_merge` the result without guards.
|
|
2375
|
+
"""
|
|
2376
|
+
try:
|
|
2377
|
+
import yaml # type: ignore[import-not-found]
|
|
2378
|
+
except ImportError:
|
|
2379
|
+
return {}
|
|
2380
|
+
if not path.exists() or not path.is_file():
|
|
2381
|
+
return {}
|
|
2382
|
+
try:
|
|
2383
|
+
text = path.read_text(encoding="utf-8")
|
|
2384
|
+
except OSError:
|
|
2385
|
+
return {}
|
|
2386
|
+
try:
|
|
2387
|
+
data = yaml.safe_load(text)
|
|
2388
|
+
except yaml.YAMLError:
|
|
2389
|
+
return {}
|
|
2390
|
+
return data if isinstance(data, dict) else {}
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
def _load_default_settings(package_root: Path) -> dict:
|
|
2394
|
+
"""Parse the rendered settings template into a defaults dict.
|
|
2395
|
+
|
|
2396
|
+
The template carries ``__COST_PROFILE__`` / ``__USER_TYPE__``
|
|
2397
|
+
placeholders that PyYAML cannot parse as scalars. We substitute the
|
|
2398
|
+
most permissive defaults (``balanced`` + empty user_type) before
|
|
2399
|
+
parsing — the resulting tree is the *defaults* layer of the merge,
|
|
2400
|
+
and downstream layers overwrite cost_profile / user_type as needed.
|
|
2401
|
+
"""
|
|
2402
|
+
template_source = package_root / "config" / "agent-settings.template.yml"
|
|
2403
|
+
if not template_source.exists():
|
|
2404
|
+
return {}
|
|
2405
|
+
try:
|
|
2406
|
+
text = template_source.read_text(encoding="utf-8")
|
|
2407
|
+
except OSError:
|
|
2408
|
+
return {}
|
|
2409
|
+
rendered = text.replace(COST_PROFILE_PLACEHOLDER, DEFAULT_PROFILE).replace(
|
|
2410
|
+
USER_TYPE_PLACEHOLDER, ""
|
|
2411
|
+
)
|
|
2412
|
+
try:
|
|
2413
|
+
import yaml # type: ignore[import-not-found]
|
|
2414
|
+
except ImportError:
|
|
2415
|
+
return {}
|
|
2416
|
+
try:
|
|
2417
|
+
data = yaml.safe_load(rendered)
|
|
2418
|
+
except yaml.YAMLError:
|
|
2419
|
+
return {}
|
|
2420
|
+
return data if isinstance(data, dict) else {}
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def read_layered_settings(
|
|
2424
|
+
package_root: Path,
|
|
2425
|
+
project_root: "Path | None" = None,
|
|
2426
|
+
) -> dict:
|
|
2427
|
+
"""Three-layer settings merge — ``defaults < global < project``.
|
|
2428
|
+
|
|
2429
|
+
``project_root`` is optional: when ``None`` (or the project file is
|
|
2430
|
+
absent), the merge collapses to ``defaults < global`` so a consumer
|
|
2431
|
+
who installs global-only sees the same effective settings the
|
|
2432
|
+
Fastify server would surface. Always returns a dict — never raises.
|
|
2433
|
+
|
|
2434
|
+
Used by :func:`main` to compute the effective configuration before
|
|
2435
|
+
rendering per-tool bridges, and by the Phase 2.4 ``settings migrate``
|
|
2436
|
+
subcommand to detect which keys are local-only overrides.
|
|
2437
|
+
"""
|
|
2438
|
+
merged = _load_default_settings(package_root)
|
|
2439
|
+
merged = deep_merge(merged, _load_yaml_doc(GLOBAL_AGENT_SETTINGS_PATH))
|
|
2440
|
+
if project_root is not None:
|
|
2441
|
+
project_file = project_root / SETTINGS_FILE
|
|
2442
|
+
merged = deep_merge(merged, _load_yaml_doc(project_file))
|
|
2443
|
+
return merged
|
|
2444
|
+
|
|
2445
|
+
|
|
2294
2446
|
def _resolve_scope(
|
|
2295
2447
|
opts: "argparse.Namespace",
|
|
2296
2448
|
detected: str,
|
|
@@ -2745,6 +2897,284 @@ def _resolve_package_root_for_global() -> Path:
|
|
|
2745
2897
|
return candidate
|
|
2746
2898
|
|
|
2747
2899
|
|
|
2900
|
+
#: Consumer bridge marker filename, relative to the project root.
|
|
2901
|
+
#: Spec: docs/contracts/consumer-bridge.md (event4u-bridge/v1).
|
|
2902
|
+
CONSUMER_BRIDGE_MARKER_RELPATH = Path("agents") / ".event4u-bridge.yml"
|
|
2903
|
+
|
|
2904
|
+
|
|
2905
|
+
# ---------------------------------------------------------------------------
|
|
2906
|
+
# Phase 5.2 — migrate-to-global first-run hook
|
|
2907
|
+
# ---------------------------------------------------------------------------
|
|
2908
|
+
#
|
|
2909
|
+
# Legacy artefacts that signal a pre-ADR-020 install in the project root.
|
|
2910
|
+
# Same surface the ``migrate-to-global`` command detects (see
|
|
2911
|
+
# ``scripts/_cli/cmd_migrate_to_global.py``). Kept in sync intentionally so
|
|
2912
|
+
# the prompt and the migration tool agree on what counts as "legacy".
|
|
2913
|
+
MIGRATE_LEGACY_YAML_FILES = (".agent-settings.yml", ".agent-user.yml")
|
|
2914
|
+
MIGRATE_LEGACY_TOOL_DIRS = (".augment", ".claude", ".cursor")
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
def _detect_legacy_for_migration(project_root: Path) -> list[str]:
|
|
2918
|
+
"""Return a sorted list of legacy artefact relpaths present in ``project_root``.
|
|
2919
|
+
|
|
2920
|
+
Skipped (returns ``[]``) when:
|
|
2921
|
+
|
|
2922
|
+
- ``AGENT_CONFIG_DEV_MODE=1`` is set (maintainer dogfood loop),
|
|
2923
|
+
- the project root IS the agent-config source repo
|
|
2924
|
+
(``.agent-src.uncompressed/`` present),
|
|
2925
|
+
- the bridge marker already exists (project is already global-only).
|
|
2926
|
+
"""
|
|
2927
|
+
if os.environ.get("AGENT_CONFIG_DEV_MODE") == "1":
|
|
2928
|
+
return []
|
|
2929
|
+
if (project_root / ".agent-src.uncompressed").is_dir():
|
|
2930
|
+
return []
|
|
2931
|
+
if (project_root / CONSUMER_BRIDGE_MARKER_RELPATH).is_file():
|
|
2932
|
+
return []
|
|
2933
|
+
|
|
2934
|
+
found: list[str] = []
|
|
2935
|
+
for name in MIGRATE_LEGACY_YAML_FILES:
|
|
2936
|
+
if (project_root / name).is_file():
|
|
2937
|
+
found.append(name)
|
|
2938
|
+
elif (project_root / "settings" / name).is_file():
|
|
2939
|
+
found.append(f"settings/{name}")
|
|
2940
|
+
for name in MIGRATE_LEGACY_TOOL_DIRS:
|
|
2941
|
+
p = project_root / name
|
|
2942
|
+
if p.is_dir() and not p.is_symlink():
|
|
2943
|
+
found.append(f"{name}/")
|
|
2944
|
+
return sorted(found)
|
|
2945
|
+
|
|
2946
|
+
|
|
2947
|
+
def _prompt_migrate_to_global(project_root: Path, artefacts: list[str]) -> bool:
|
|
2948
|
+
"""Ask the user whether to run ``migrate-to-global`` now.
|
|
2949
|
+
|
|
2950
|
+
Interactive TTY → ``[Y/n]`` prompt (Enter = yes). Non-interactive (CI
|
|
2951
|
+
or no TTY) → auto-yes per roadmap Phase 5.2 contract. Three invalid
|
|
2952
|
+
replies short-circuit to "no" (defensive, never blocks the install).
|
|
2953
|
+
"""
|
|
2954
|
+
if not QUIET:
|
|
2955
|
+
print()
|
|
2956
|
+
warn("Legacy project-local artefacts detected — pre-ADR-020 layout:")
|
|
2957
|
+
for rel in artefacts:
|
|
2958
|
+
info(f" {project_root / rel}")
|
|
2959
|
+
info("ADR-020 ships consumer installs as global-only.")
|
|
2960
|
+
info("`agent-config migrate-to-global` copies → verifies → moves them safely.")
|
|
2961
|
+
|
|
2962
|
+
if not _is_interactive():
|
|
2963
|
+
if not QUIET:
|
|
2964
|
+
info("Non-interactive mode → defaulting to YES (run migration).")
|
|
2965
|
+
return True
|
|
2966
|
+
|
|
2967
|
+
attempts = 0
|
|
2968
|
+
while attempts < 3:
|
|
2969
|
+
try:
|
|
2970
|
+
reply = _read_line("Run `agent-config migrate-to-global` now? [Y/n]: ")
|
|
2971
|
+
except EOFError:
|
|
2972
|
+
return False
|
|
2973
|
+
if reply == "" or reply.lower() in ("y", "yes"):
|
|
2974
|
+
return True
|
|
2975
|
+
if reply.lower() in ("n", "no"):
|
|
2976
|
+
return False
|
|
2977
|
+
attempts += 1
|
|
2978
|
+
warn(f"Invalid choice '{reply}'. Enter Y or n.")
|
|
2979
|
+
return False
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
def _run_migrate_to_global(project_root: Path) -> int:
|
|
2983
|
+
"""Invoke ``cmd_migrate_to_global._do_migrate`` against ``project_root``.
|
|
2984
|
+
|
|
2985
|
+
Returns the migrator's exit code so the caller can abort the install
|
|
2986
|
+
on failure. The perms gate is skipped because the install path runs
|
|
2987
|
+
its own checks; surfacing two perm errors back-to-back would be
|
|
2988
|
+
confusing for first-run users.
|
|
2989
|
+
"""
|
|
2990
|
+
import importlib # noqa: PLC0415 — local to keep startup lean.
|
|
2991
|
+
|
|
2992
|
+
try:
|
|
2993
|
+
cmd_mod = importlib.import_module("scripts._cli.cmd_migrate_to_global")
|
|
2994
|
+
except ImportError as exc:
|
|
2995
|
+
warn(f"migrate-to-global unavailable: {exc}")
|
|
2996
|
+
return 1
|
|
2997
|
+
|
|
2998
|
+
install_mod = sys.modules[__name__]
|
|
2999
|
+
return cmd_mod._do_migrate(project_root, force=False, install_mod=install_mod, out=sys.stdout)
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
def _format_global_root_for_marker(global_root: Path) -> str:
|
|
3003
|
+
"""Render ``global_root`` for the bridge marker.
|
|
3004
|
+
|
|
3005
|
+
Per ``docs/contracts/consumer-bridge.md``, readers MUST expand ``~``
|
|
3006
|
+
against the **current process's** ``$HOME``. To keep the marker
|
|
3007
|
+
portable across maintainer home dirs, render the path with a
|
|
3008
|
+
leading ``~/`` when it lives under ``Path.home()``; fall back to
|
|
3009
|
+
the absolute path otherwise (e.g. ``EVENT4U_CONFIG_HOME`` override
|
|
3010
|
+
pointing outside ``$HOME``).
|
|
3011
|
+
"""
|
|
3012
|
+
try:
|
|
3013
|
+
rel = global_root.resolve().relative_to(Path.home().resolve())
|
|
3014
|
+
except ValueError:
|
|
3015
|
+
return str(global_root)
|
|
3016
|
+
return f"~/{rel.as_posix()}"
|
|
3017
|
+
|
|
3018
|
+
|
|
3019
|
+
def _write_consumer_bridge_marker(
|
|
3020
|
+
project_root: Path,
|
|
3021
|
+
installer_version: str,
|
|
3022
|
+
*,
|
|
3023
|
+
env: Optional[dict] = None,
|
|
3024
|
+
now: Optional[datetime] = None,
|
|
3025
|
+
) -> Optional[Path]:
|
|
3026
|
+
"""Write ``agents/.event4u-bridge.yml`` at the consumer project root.
|
|
3027
|
+
|
|
3028
|
+
Returns the written path, or ``None`` when the write was skipped per
|
|
3029
|
+
``docs/contracts/consumer-bridge.md`` § Writer contract:
|
|
3030
|
+
|
|
3031
|
+
- ``AGENT_CONFIG_DEV_MODE=1`` (maintainer dev installs never lay the
|
|
3032
|
+
bridge into the source repo).
|
|
3033
|
+
- The project root is the agent-config source repo itself
|
|
3034
|
+
(``.agent-src.uncompressed/`` present) — same rationale.
|
|
3035
|
+
|
|
3036
|
+
Atomic write: ``tempfile`` in the same dir + ``os.replace``. Same
|
|
3037
|
+
pattern the lockfile uses (see ``scripts/_lib/installed_lock.py``).
|
|
3038
|
+
Mode ``0o644`` per contract — no secrets, world-readable.
|
|
3039
|
+
"""
|
|
3040
|
+
import tempfile
|
|
3041
|
+
|
|
3042
|
+
env_map = env if env is not None else os.environ
|
|
3043
|
+
if env_map.get("AGENT_CONFIG_DEV_MODE") == "1":
|
|
3044
|
+
return None
|
|
3045
|
+
if (project_root / ".agent-src.uncompressed").is_dir():
|
|
3046
|
+
return None
|
|
3047
|
+
|
|
3048
|
+
paths_mod = _load_user_global_paths_module()
|
|
3049
|
+
global_root_str = _format_global_root_for_marker(paths_mod.event4u_root(env=env_map))
|
|
3050
|
+
stamp = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
3051
|
+
|
|
3052
|
+
body = (
|
|
3053
|
+
"# event4u/agent-config — consumer bridge marker (auto-written).\n"
|
|
3054
|
+
"# Spec: docs/contracts/consumer-bridge.md (event4u-bridge/v1).\n"
|
|
3055
|
+
"# Reader contract: expand ~ against the current $HOME; fail closed\n"
|
|
3056
|
+
"# when global_root is missing on disk; never write back through it.\n"
|
|
3057
|
+
"schema: event4u-bridge/v1\n"
|
|
3058
|
+
f"global_root: {global_root_str}\n"
|
|
3059
|
+
f"installed_at: {stamp}\n"
|
|
3060
|
+
f"installer_version: {installer_version}\n"
|
|
3061
|
+
)
|
|
3062
|
+
|
|
3063
|
+
target = project_root / CONSUMER_BRIDGE_MARKER_RELPATH
|
|
3064
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3065
|
+
|
|
3066
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
3067
|
+
prefix=".event4u-bridge.", suffix=".yml.tmp",
|
|
3068
|
+
dir=str(target.parent), text=False,
|
|
3069
|
+
)
|
|
3070
|
+
try:
|
|
3071
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
3072
|
+
fh.write(body)
|
|
3073
|
+
os.chmod(tmp_name, 0o644)
|
|
3074
|
+
os.replace(tmp_name, target)
|
|
3075
|
+
except Exception:
|
|
3076
|
+
try:
|
|
3077
|
+
os.unlink(tmp_name)
|
|
3078
|
+
except OSError:
|
|
3079
|
+
pass
|
|
3080
|
+
raise
|
|
3081
|
+
return target
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
#: Per-tool project anchors (Phase 4.3). Some AI tools only load rules
|
|
3085
|
+
#: when an anchor file is **inside** the workspace. For those IDs we
|
|
3086
|
+
#: plant a thin pointer file under the tool's per-project directory
|
|
3087
|
+
#: whose body references the bridge marker (``agents/.event4u-bridge.yml``).
|
|
3088
|
+
#: Tools that load purely from user-scope (Claude Code, Cursor, Augment)
|
|
3089
|
+
#: read the marker once and need no per-tool file — they are absent
|
|
3090
|
+
#: from this map by design (see ``docs/contracts/consumer-bridge.md``
|
|
3091
|
+
#: § Per-tool anchor strategy).
|
|
3092
|
+
PROJECT_ANCHOR_TOOLS: dict[str, str] = {
|
|
3093
|
+
"windsurf": ".windsurf/agent-config.bridge.yml",
|
|
3094
|
+
"cline": ".clinerules/agent-config.bridge.yml",
|
|
3095
|
+
"gemini-cli": ".gemini/agent-config.bridge.yml",
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
|
|
3099
|
+
def _write_per_tool_project_anchors(
|
|
3100
|
+
project_root: Path,
|
|
3101
|
+
tools: set[str],
|
|
3102
|
+
*,
|
|
3103
|
+
env: Optional[dict] = None,
|
|
3104
|
+
now: Optional[datetime] = None,
|
|
3105
|
+
) -> list[Path]:
|
|
3106
|
+
"""Plant thin pointer files for tools in :data:`PROJECT_ANCHOR_TOOLS`.
|
|
3107
|
+
|
|
3108
|
+
Each pointer is a tiny YAML body that references the bridge marker
|
|
3109
|
+
at ``agents/.event4u-bridge.yml`` (relative from the pointer's
|
|
3110
|
+
location) plus the resolved ``global_root`` for convenience. Same
|
|
3111
|
+
gate semantics as :func:`_write_consumer_bridge_marker`:
|
|
3112
|
+
|
|
3113
|
+
- Skipped under ``AGENT_CONFIG_DEV_MODE=1``.
|
|
3114
|
+
- Skipped inside the agent-config source repo
|
|
3115
|
+
(``.agent-src.uncompressed/`` present).
|
|
3116
|
+
- Skipped when the tool is not in ``tools``.
|
|
3117
|
+
|
|
3118
|
+
Atomic write per file (temp file + ``os.replace``); ``0o644``
|
|
3119
|
+
permissions per ``docs/contracts/consumer-bridge.md`` (the pointers
|
|
3120
|
+
contain no secrets, only paths).
|
|
3121
|
+
"""
|
|
3122
|
+
import tempfile
|
|
3123
|
+
|
|
3124
|
+
env_map = env if env is not None else os.environ
|
|
3125
|
+
if env_map.get("AGENT_CONFIG_DEV_MODE") == "1":
|
|
3126
|
+
return []
|
|
3127
|
+
if (project_root / ".agent-src.uncompressed").is_dir():
|
|
3128
|
+
return []
|
|
3129
|
+
|
|
3130
|
+
paths_mod = _load_user_global_paths_module()
|
|
3131
|
+
global_root_str = _format_global_root_for_marker(paths_mod.event4u_root(env=env_map))
|
|
3132
|
+
stamp = (now or datetime.now(timezone.utc)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
3133
|
+
written: list[Path] = []
|
|
3134
|
+
|
|
3135
|
+
for tool_id, rel_path in sorted(PROJECT_ANCHOR_TOOLS.items()):
|
|
3136
|
+
if tool_id not in tools:
|
|
3137
|
+
continue
|
|
3138
|
+
target = project_root / rel_path
|
|
3139
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
3140
|
+
|
|
3141
|
+
# Relative path from the pointer file back to the bridge marker.
|
|
3142
|
+
# Both live inside ``project_root``; ``os.path.relpath`` keeps the
|
|
3143
|
+
# result portable across machines (no absolute path leakage).
|
|
3144
|
+
bridge_abs = project_root / CONSUMER_BRIDGE_MARKER_RELPATH
|
|
3145
|
+
bridge_rel = os.path.relpath(bridge_abs, target.parent)
|
|
3146
|
+
|
|
3147
|
+
body = (
|
|
3148
|
+
"# event4u/agent-config — per-tool project anchor (auto-written).\n"
|
|
3149
|
+
"# Spec: docs/contracts/consumer-bridge.md § Per-tool anchor strategy.\n"
|
|
3150
|
+
f"# Tool: {tool_id}. Bridge marker: agents/.event4u-bridge.yml.\n"
|
|
3151
|
+
"schema: event4u-bridge/v1\n"
|
|
3152
|
+
f"tool: {tool_id}\n"
|
|
3153
|
+
f"bridge: {bridge_rel}\n"
|
|
3154
|
+
f"global_root: {global_root_str}\n"
|
|
3155
|
+
f"installed_at: {stamp}\n"
|
|
3156
|
+
)
|
|
3157
|
+
|
|
3158
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
3159
|
+
prefix=".agent-config.bridge.", suffix=".yml.tmp",
|
|
3160
|
+
dir=str(target.parent), text=False,
|
|
3161
|
+
)
|
|
3162
|
+
try:
|
|
3163
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
3164
|
+
fh.write(body)
|
|
3165
|
+
os.chmod(tmp_name, 0o644)
|
|
3166
|
+
os.replace(tmp_name, target)
|
|
3167
|
+
except Exception:
|
|
3168
|
+
try:
|
|
3169
|
+
os.unlink(tmp_name)
|
|
3170
|
+
except OSError:
|
|
3171
|
+
pass
|
|
3172
|
+
raise
|
|
3173
|
+
written.append(target)
|
|
3174
|
+
|
|
3175
|
+
return written
|
|
3176
|
+
|
|
3177
|
+
|
|
2748
3178
|
#: Inline package identifier injected into deployed Markdown
|
|
2749
3179
|
#: frontmatter (P5.1). Human-readable provenance only; the manifest
|
|
2750
3180
|
#: remains the authoritative ownership source (see P5.3).
|
|
@@ -3137,6 +3567,38 @@ def install_global(
|
|
|
3137
3567
|
if rc != 0:
|
|
3138
3568
|
return rc
|
|
3139
3569
|
|
|
3570
|
+
# Consumer bridge marker (Phase 4.2). One declarative pointer at
|
|
3571
|
+
# ``agents/.event4u-bridge.yml`` lets per-tool adapters locate
|
|
3572
|
+
# the global root from inside the repo. Skipped in maintainer
|
|
3573
|
+
# dev mode and in the source repo (see contract § Writer
|
|
3574
|
+
# contract; the surrounding ``.agent-src.uncompressed`` guard
|
|
3575
|
+
# already covers the source-repo case, the dev-mode skip is
|
|
3576
|
+
# enforced inside the writer).
|
|
3577
|
+
marker_path = _write_consumer_bridge_marker(project_root, installed_version)
|
|
3578
|
+
if marker_path is not None and not QUIET:
|
|
3579
|
+
rel = (
|
|
3580
|
+
marker_path.relative_to(project_root)
|
|
3581
|
+
if marker_path.is_relative_to(project_root)
|
|
3582
|
+
else marker_path
|
|
3583
|
+
)
|
|
3584
|
+
info(f"Bridge marker written: {rel}")
|
|
3585
|
+
|
|
3586
|
+
# Per-tool project anchors (Phase 4.3). Plant thin pointer files
|
|
3587
|
+
# for tools that only load rules when an anchor exists inside
|
|
3588
|
+
# the workspace (Windsurf, Cline, Gemini-CLI). Same dev-mode +
|
|
3589
|
+
# source-repo gate as the bridge marker (enforced inside the
|
|
3590
|
+
# writer). Filter to the tools the caller actually selected so
|
|
3591
|
+
# we never plant anchors for tools the user excluded.
|
|
3592
|
+
anchor_paths = _write_per_tool_project_anchors(project_root, tools)
|
|
3593
|
+
if anchor_paths and not QUIET:
|
|
3594
|
+
for p in anchor_paths:
|
|
3595
|
+
rel = (
|
|
3596
|
+
p.relative_to(project_root)
|
|
3597
|
+
if p.is_relative_to(project_root)
|
|
3598
|
+
else p
|
|
3599
|
+
)
|
|
3600
|
+
info(f"Project anchor written: {rel}")
|
|
3601
|
+
|
|
3140
3602
|
if not QUIET:
|
|
3141
3603
|
print()
|
|
3142
3604
|
success("Global install completed.")
|
|
@@ -3304,6 +3766,43 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
|
|
|
3304
3766
|
"docs/contracts/universal-skills.md for the always-loaded set."
|
|
3305
3767
|
),
|
|
3306
3768
|
)
|
|
3769
|
+
parser.add_argument(
|
|
3770
|
+
"--no-ui",
|
|
3771
|
+
dest="no_ui",
|
|
3772
|
+
action="store_true",
|
|
3773
|
+
help=(
|
|
3774
|
+
"suppress the post-install browser-wizard auto-launch. Also "
|
|
3775
|
+
"honored via AGENT_CONFIG_NO_UI=1 env. CI runners (CI=1) and "
|
|
3776
|
+
"non-TTY stdouts auto-suppress regardless of this flag. See "
|
|
3777
|
+
"agents/roadmaps/wizard-install-py-wiring.md."
|
|
3778
|
+
),
|
|
3779
|
+
)
|
|
3780
|
+
parser.add_argument(
|
|
3781
|
+
"--dry-run",
|
|
3782
|
+
dest="dry_run",
|
|
3783
|
+
action="store_true",
|
|
3784
|
+
help=(
|
|
3785
|
+
"print a plan summary of what would be installed (profile, "
|
|
3786
|
+
"scope, tools, wizard auto-launch decision) and exit 0 "
|
|
3787
|
+
"without writing any files or spawning subprocesses. "
|
|
3788
|
+
"Distinct from the internal alias-resolution --dry-run "
|
|
3789
|
+
"passed to bridge sub-invocations."
|
|
3790
|
+
),
|
|
3791
|
+
)
|
|
3792
|
+
parser.add_argument(
|
|
3793
|
+
"--apply-payload",
|
|
3794
|
+
dest="apply_payload",
|
|
3795
|
+
default=None,
|
|
3796
|
+
help=(
|
|
3797
|
+
"path to a WizardApplyPayload JSON file (schemas/"
|
|
3798
|
+
"wizard-apply-payload.schema.json). When supplied, install.py "
|
|
3799
|
+
"reads the payload, validates schema_version, translates "
|
|
3800
|
+
"tools/packs/settings into CLI equivalents, and dispatches "
|
|
3801
|
+
"as if those flags were passed directly. Combine with "
|
|
3802
|
+
"--dry-run for the Phase 1.5 preview path "
|
|
3803
|
+
"(road-to-global-only-install § D12 / Phase 1.5)."
|
|
3804
|
+
),
|
|
3805
|
+
)
|
|
3307
3806
|
opts = parser.parse_args(argv)
|
|
3308
3807
|
opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
|
|
3309
3808
|
if opts.scope == "global" and opts.custom_path:
|
|
@@ -3411,19 +3910,26 @@ def _write_install_mode_marker(project_root: Path, mode: str) -> None:
|
|
|
3411
3910
|
|
|
3412
3911
|
|
|
3413
3912
|
def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
|
|
3414
|
-
"""Bootstrap the project-local override layer only (
|
|
3913
|
+
"""Bootstrap the project-local override layer only (ADR-020-compliant).
|
|
3415
3914
|
|
|
3416
|
-
Writes:
|
|
3915
|
+
Writes the global-only consumer scaffold:
|
|
3417
3916
|
|
|
3418
|
-
* ``agents/.gitkeep`` so the
|
|
3419
|
-
|
|
3420
|
-
|
|
3917
|
+
* ``agents/overrides/{rules,skills,commands}/.gitkeep`` so the
|
|
3918
|
+
override subdirs are committable in a fresh repo.
|
|
3919
|
+
* ``agents/overrides/README.md`` explaining the override layer and
|
|
3920
|
+
its resolution model.
|
|
3921
|
+
* ``agents/.event4u-bridge.yml`` (Phase 4.2) anchoring the project
|
|
3922
|
+
to the user-global ``~/.event4u/agent-config/`` install.
|
|
3923
|
+
* ``.agent-settings.yml`` — only when ``user_type`` is supplied
|
|
3924
|
+
(back-compat with the step-9 interactive flow); otherwise the
|
|
3925
|
+
project-local settings file is **not** written (global config
|
|
3926
|
+
is the source of truth per ADR-020 § D2).
|
|
3421
3927
|
|
|
3422
3928
|
Refuses (exit 1) when ``target_root`` is **inside** an existing
|
|
3423
3929
|
agent-config project (Phase-1 anchor walk above the target). The
|
|
3424
3930
|
in-target case is allowed and treated as idempotent — re-running
|
|
3425
|
-
``--minimal`` in a folder that already has
|
|
3426
|
-
|
|
3931
|
+
``--minimal`` in a folder that already has the bridge marker does
|
|
3932
|
+
nothing unless ``--force`` is passed.
|
|
3427
3933
|
|
|
3428
3934
|
Does **not** touch ``.gitignore`` (D2 — user owns the ignore file).
|
|
3429
3935
|
The ``./agent-config`` wrapper is installed by ``scripts/install.sh``
|
|
@@ -3455,40 +3961,71 @@ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
|
|
|
3455
3961
|
|
|
3456
3962
|
templates = _minimal_templates_root()
|
|
3457
3963
|
settings_src = templates / SETTINGS_FILE
|
|
3458
|
-
|
|
3964
|
+
overrides_gitkeep_src = templates / "overrides-gitkeep"
|
|
3965
|
+
overrides_readme_src = templates / "agents-overrides-readme.md"
|
|
3459
3966
|
|
|
3460
|
-
if not settings_src.is_file()
|
|
3461
|
-
fail(f"Bundled minimal
|
|
3967
|
+
if not settings_src.is_file():
|
|
3968
|
+
fail(f"Bundled minimal settings template missing under {templates}")
|
|
3969
|
+
if not overrides_gitkeep_src.is_file() or not overrides_readme_src.is_file():
|
|
3970
|
+
fail(f"Bundled overrides scaffold templates missing under {templates}")
|
|
3462
3971
|
|
|
3463
3972
|
info(f"Minimal init → {target_root}")
|
|
3464
3973
|
|
|
3465
|
-
# 1. agents/.gitkeep
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3974
|
+
# 1. agents/overrides/{rules,skills,commands}/.gitkeep — committable
|
|
3975
|
+
# scaffold for the project-local override layer (ADR-020 § Phase 4.5).
|
|
3976
|
+
overrides_root = target_root / "agents" / "overrides"
|
|
3977
|
+
overrides_root.mkdir(parents=True, exist_ok=True)
|
|
3978
|
+
gitkeep_body = overrides_gitkeep_src.read_text(encoding="utf-8")
|
|
3979
|
+
for sub in ("rules", "skills", "commands"):
|
|
3980
|
+
sub_dir = overrides_root / sub
|
|
3981
|
+
sub_dir.mkdir(exist_ok=True)
|
|
3982
|
+
gitkeep_dst = sub_dir / ".gitkeep"
|
|
3983
|
+
if gitkeep_dst.exists() and not force:
|
|
3984
|
+
skip(f"agents/overrides/{sub}/.gitkeep already exists (use --force to overwrite)")
|
|
3985
|
+
else:
|
|
3986
|
+
gitkeep_dst.write_text(gitkeep_body, encoding="utf-8")
|
|
3987
|
+
success(f"Wrote agents/overrides/{sub}/.gitkeep")
|
|
3474
3988
|
|
|
3475
|
-
# 2. .
|
|
3476
|
-
|
|
3477
|
-
if
|
|
3478
|
-
skip(
|
|
3989
|
+
# 2. agents/overrides/README.md — explains the override layer.
|
|
3990
|
+
readme_dst = overrides_root / "README.md"
|
|
3991
|
+
if readme_dst.exists() and not force:
|
|
3992
|
+
skip("agents/overrides/README.md already exists (use --force to overwrite)")
|
|
3479
3993
|
else:
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3994
|
+
readme_dst.write_text(overrides_readme_src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
3995
|
+
success("Wrote agents/overrides/README.md")
|
|
3996
|
+
|
|
3997
|
+
# 3. .agent-settings.yml stub — only when user_type is supplied
|
|
3998
|
+
# (back-compat with the step-9 interactive flow). Global config is
|
|
3999
|
+
# the source of truth per ADR-020 § D2; a fresh `--minimal` run
|
|
4000
|
+
# without user_type does not write a project-local settings file.
|
|
4001
|
+
if user_type:
|
|
4002
|
+
settings_dst = target_root / SETTINGS_FILE
|
|
4003
|
+
if settings_dst.exists() and not force:
|
|
4004
|
+
skip(f"{SETTINGS_FILE} already exists (use --force to overwrite)")
|
|
4005
|
+
else:
|
|
4006
|
+
body = settings_src.read_text(encoding="utf-8").rstrip() + (
|
|
3483
4007
|
"\n\n# --- Personal (step-9 user-type axis) ---\n"
|
|
3484
4008
|
"personal:\n"
|
|
3485
4009
|
f" user_type: {user_type}\n"
|
|
3486
4010
|
)
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
success(f"Wrote {SETTINGS_FILE}{suffix}")
|
|
4011
|
+
settings_dst.write_text(body, encoding="utf-8")
|
|
4012
|
+
success(f"Wrote {SETTINGS_FILE} (user_type={user_type})")
|
|
3490
4013
|
|
|
3491
|
-
#
|
|
4014
|
+
# 4. Consumer bridge marker (Phase 4.2). Anchors the project to
|
|
4015
|
+
# the user-global ``~/.event4u/agent-config/`` install. The writer
|
|
4016
|
+
# itself enforces the dev-mode + source-repo skip contract.
|
|
4017
|
+
lock_mod = _load_installed_lock_module()
|
|
4018
|
+
installed_version = lock_mod.current_package_version()
|
|
4019
|
+
marker_path = _write_consumer_bridge_marker(target_root, installed_version)
|
|
4020
|
+
if marker_path is not None:
|
|
4021
|
+
rel = (
|
|
4022
|
+
marker_path.relative_to(target_root)
|
|
4023
|
+
if marker_path.is_relative_to(target_root)
|
|
4024
|
+
else marker_path
|
|
4025
|
+
)
|
|
4026
|
+
success(f"Wrote {rel}")
|
|
4027
|
+
|
|
4028
|
+
# 5. install-mode marker (Step 8 A5) — authoritative state for
|
|
3492
4029
|
# doctor --context and future install-aware tooling. Written even
|
|
3493
4030
|
# on idempotent re-runs so the marker is repaired if removed.
|
|
3494
4031
|
_write_install_mode_marker(target_root, "minimal")
|
|
@@ -3508,7 +4045,7 @@ def install_minimal(target_root: Path, force: bool, user_type: str = "") -> int:
|
|
|
3508
4045
|
print()
|
|
3509
4046
|
info("Next steps:")
|
|
3510
4047
|
info(" • Ensure `agent-config` is on $PATH: npm install -g @event4u/agent-config")
|
|
3511
|
-
info(" •
|
|
4048
|
+
info(" • Drop project-scoped overrides under `agents/overrides/{rules,skills,commands}/`.")
|
|
3512
4049
|
info(" • Run `agent-config doctor` to verify the layer is picked up.")
|
|
3513
4050
|
return 0
|
|
3514
4051
|
|
|
@@ -3627,14 +4164,316 @@ def run_interactive_init(project_root: Path, force: bool) -> int:
|
|
|
3627
4164
|
return 0
|
|
3628
4165
|
|
|
3629
4166
|
|
|
4167
|
+
# --- Wizard auto-launch (Phase 6 follow-up) ---
|
|
4168
|
+
#
|
|
4169
|
+
# Auto-launches the browser configuration wizard at the tail of a
|
|
4170
|
+
# successful install. The TS side ships a `gui` subcommand on the
|
|
4171
|
+
# installer CLI; this Python parent acts as a supervisor that:
|
|
4172
|
+
#
|
|
4173
|
+
# 1. evaluates gate conditions (TTY, CI, --no-ui, env override),
|
|
4174
|
+
# 2. validates the dist exists,
|
|
4175
|
+
# 3. spawns `node <cli> gui --project-root <root>` via subprocess.Popen,
|
|
4176
|
+
# 4. captures stderr on a background thread (for failure surfacing),
|
|
4177
|
+
# 5. reads stdout line-by-line with a progressive timeout
|
|
4178
|
+
# (10s → 20s → 40s → 80s) and matches the strict readiness regex
|
|
4179
|
+
# `^WIZARD_READY url=(http://(?:127.0.0.1|localhost):\d+/)\r?$`,
|
|
4180
|
+
# 6. on success: prints the URL banner and waits for the child to
|
|
4181
|
+
# exit (Ctrl-C in the parent terminal propagates to the child),
|
|
4182
|
+
# 7. on timeout: kills the child, prints captured stderr tail, falls
|
|
4183
|
+
# through to a fallback message; install itself is unaffected.
|
|
4184
|
+
#
|
|
4185
|
+
# Council synthesis: agents/runtime/council/responses/wizard-wiring-2026-05-22.synthesis.md
|
|
4186
|
+
# Roadmap: agents/roadmaps/wizard-install-py-wiring.md Step 3.
|
|
4187
|
+
|
|
4188
|
+
_WIZARD_READY_RE = re.compile(
|
|
4189
|
+
r"^WIZARD_READY url=(http://(?:127\.0\.0\.1|localhost):\d+/)\r?$"
|
|
4190
|
+
)
|
|
4191
|
+
_WIZARD_TIMEOUTS = (10.0, 20.0, 40.0, 80.0) # cumulative budget 150s.
|
|
4192
|
+
|
|
4193
|
+
|
|
4194
|
+
def _wizard_should_launch(opts: argparse.Namespace) -> tuple[bool, str]:
|
|
4195
|
+
"""Evaluate gate conditions for the post-install wizard auto-launch.
|
|
4196
|
+
|
|
4197
|
+
Returns (decision, reason). When decision is False the reason
|
|
4198
|
+
string explains why (CI / no-tty / --no-ui / env override) and is
|
|
4199
|
+
suitable for the pre-install banner Council Tier 2 § 8.
|
|
4200
|
+
"""
|
|
4201
|
+
if getattr(opts, "no_ui", False):
|
|
4202
|
+
return (False, "--no-ui flag set")
|
|
4203
|
+
env_no_ui = os.environ.get("AGENT_CONFIG_NO_UI", "").strip()
|
|
4204
|
+
if env_no_ui and env_no_ui != "0":
|
|
4205
|
+
return (False, "AGENT_CONFIG_NO_UI env set")
|
|
4206
|
+
if os.environ.get("CI", "").strip():
|
|
4207
|
+
return (False, "CI environment detected")
|
|
4208
|
+
if not sys.stdout.isatty():
|
|
4209
|
+
return (False, "stdout is not a TTY")
|
|
4210
|
+
return (True, "")
|
|
4211
|
+
|
|
4212
|
+
|
|
4213
|
+
def _wizard_cli_dist(project_root: Path) -> Path | None:
|
|
4214
|
+
"""Resolve the installer dist path. Returns None if not built.
|
|
4215
|
+
|
|
4216
|
+
Walks up from this file (scripts/install.py is at <pkg>/scripts/)
|
|
4217
|
+
to <pkg>/packages/core/installer/dist/cli.js. That's the layout
|
|
4218
|
+
the monorepo ships; consumer installs run `node <pkg>/.../cli.js`.
|
|
4219
|
+
"""
|
|
4220
|
+
package_root = Path(__file__).resolve().parent.parent
|
|
4221
|
+
cli = package_root / "packages" / "core" / "installer" / "dist" / "cli.js"
|
|
4222
|
+
return cli if cli.exists() else None
|
|
4223
|
+
|
|
4224
|
+
|
|
4225
|
+
def _wizard_spawn(project_root: Path) -> int:
|
|
4226
|
+
"""Spawn the wizard, await readiness, hand off to the child.
|
|
4227
|
+
|
|
4228
|
+
Returns the child's exit code on clean shutdown, 0 on
|
|
4229
|
+
readiness-timeout (install itself succeeded; wizard is best-effort).
|
|
4230
|
+
Never raises into the parent — every error surfaces as a printed
|
|
4231
|
+
fallback line and a 0 return.
|
|
4232
|
+
"""
|
|
4233
|
+
cli = _wizard_cli_dist(project_root)
|
|
4234
|
+
if cli is None:
|
|
4235
|
+
print(
|
|
4236
|
+
"(Wizard not available — installer package not built. "
|
|
4237
|
+
"Run 'npm run build' inside packages/core/installer/.)"
|
|
4238
|
+
)
|
|
4239
|
+
return 0
|
|
4240
|
+
|
|
4241
|
+
cmd = ["node", str(cli), "gui", "--project-root", str(project_root)]
|
|
4242
|
+
env = os.environ.copy()
|
|
4243
|
+
# The Node child writes its own readiness banner; suppress the
|
|
4244
|
+
# browser-open inside the child so the Python parent stays in
|
|
4245
|
+
# charge of the user-facing URL print (Tier 2 § 8 ordering).
|
|
4246
|
+
env.setdefault("AGENT_CONFIG_GUI_NO_OPEN", "1")
|
|
4247
|
+
|
|
4248
|
+
try:
|
|
4249
|
+
child = subprocess.Popen( # noqa: S603 - cmd is locally-built, not user input
|
|
4250
|
+
cmd,
|
|
4251
|
+
stdout=subprocess.PIPE,
|
|
4252
|
+
stderr=subprocess.PIPE,
|
|
4253
|
+
text=True,
|
|
4254
|
+
env=env,
|
|
4255
|
+
bufsize=1, # line-buffered
|
|
4256
|
+
)
|
|
4257
|
+
except OSError as exc:
|
|
4258
|
+
print(f"(Wizard failed to start: {exc}; run 'node {cli} gui' manually.)")
|
|
4259
|
+
return 0
|
|
4260
|
+
|
|
4261
|
+
# Drain stderr on a background thread so a chatty child can't
|
|
4262
|
+
# block the readline loop below. Cap at 80 lines to bound memory.
|
|
4263
|
+
stderr_tail: list[str] = []
|
|
4264
|
+
|
|
4265
|
+
def _drain_stderr() -> None:
|
|
4266
|
+
if child.stderr is None:
|
|
4267
|
+
return
|
|
4268
|
+
for line in child.stderr:
|
|
4269
|
+
stderr_tail.append(line.rstrip("\r\n"))
|
|
4270
|
+
if len(stderr_tail) > 80:
|
|
4271
|
+
del stderr_tail[: len(stderr_tail) - 80]
|
|
4272
|
+
|
|
4273
|
+
stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
|
|
4274
|
+
stderr_thread.start()
|
|
4275
|
+
|
|
4276
|
+
return _wizard_await_ready(child, stderr_tail, cli)
|
|
4277
|
+
|
|
4278
|
+
|
|
4279
|
+
def _wizard_await_ready(
|
|
4280
|
+
child: subprocess.Popen[str],
|
|
4281
|
+
stderr_tail: list[str],
|
|
4282
|
+
cli: Path,
|
|
4283
|
+
) -> int:
|
|
4284
|
+
"""Read child stdout until the WIZARD_READY regex matches.
|
|
4285
|
+
|
|
4286
|
+
Progressive backoff per Council Tier 1 § 2. On match, prints the
|
|
4287
|
+
URL banner and blocks on child.wait() (parent Ctrl-C is forwarded
|
|
4288
|
+
to the child by the OS via the shared process group).
|
|
4289
|
+
"""
|
|
4290
|
+
assert child.stdout is not None
|
|
4291
|
+
elapsed_total = 0.0
|
|
4292
|
+
matched_url: str | None = None
|
|
4293
|
+
|
|
4294
|
+
for interim in _WIZARD_TIMEOUTS:
|
|
4295
|
+
deadline = time.monotonic() + interim
|
|
4296
|
+
while True:
|
|
4297
|
+
remaining = deadline - time.monotonic()
|
|
4298
|
+
if remaining <= 0:
|
|
4299
|
+
break
|
|
4300
|
+
# readline blocks until \n or EOF; we cap total wait per
|
|
4301
|
+
# phase via deadline. Use poll() to detect child exit.
|
|
4302
|
+
if child.poll() is not None:
|
|
4303
|
+
break
|
|
4304
|
+
line = child.stdout.readline()
|
|
4305
|
+
if not line:
|
|
4306
|
+
# EOF — child closed stdout without WIZARD_READY.
|
|
4307
|
+
break
|
|
4308
|
+
m = _WIZARD_READY_RE.match(line)
|
|
4309
|
+
if m is not None:
|
|
4310
|
+
matched_url = m.group(1)
|
|
4311
|
+
break
|
|
4312
|
+
if matched_url is not None or child.poll() is not None:
|
|
4313
|
+
break
|
|
4314
|
+
elapsed_total += interim
|
|
4315
|
+
if elapsed_total < sum(_WIZARD_TIMEOUTS):
|
|
4316
|
+
print(f"(Wizard still booting after {int(elapsed_total)}s — waiting…)")
|
|
4317
|
+
|
|
4318
|
+
if matched_url is None:
|
|
4319
|
+
try:
|
|
4320
|
+
child.terminate()
|
|
4321
|
+
child.wait(timeout=2)
|
|
4322
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
4323
|
+
try:
|
|
4324
|
+
child.kill()
|
|
4325
|
+
except OSError:
|
|
4326
|
+
pass
|
|
4327
|
+
tail = "\n ".join(stderr_tail[-20:]) if stderr_tail else "(no stderr captured)"
|
|
4328
|
+
print(
|
|
4329
|
+
f"(Wizard server boot timed out after {int(sum(_WIZARD_TIMEOUTS))}s; "
|
|
4330
|
+
f"run 'node {cli} gui' manually.)\n"
|
|
4331
|
+
f" Last stderr:\n {tail}"
|
|
4332
|
+
)
|
|
4333
|
+
return 0
|
|
4334
|
+
|
|
4335
|
+
print()
|
|
4336
|
+
print(f"Setup wizard ready: {matched_url}")
|
|
4337
|
+
print("(Wizard runs in the background; close the tab or press Ctrl-C to stop.)")
|
|
4338
|
+
try:
|
|
4339
|
+
return child.wait()
|
|
4340
|
+
except KeyboardInterrupt:
|
|
4341
|
+
try:
|
|
4342
|
+
child.terminate()
|
|
4343
|
+
return child.wait(timeout=5)
|
|
4344
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
4345
|
+
try:
|
|
4346
|
+
child.kill()
|
|
4347
|
+
except OSError:
|
|
4348
|
+
pass
|
|
4349
|
+
return 130
|
|
4350
|
+
|
|
4351
|
+
|
|
4352
|
+
def _dry_run_summary(opts: argparse.Namespace) -> int:
|
|
4353
|
+
"""Print a one-block plan summary for --dry-run and exit 0.
|
|
4354
|
+
|
|
4355
|
+
Lists profile, scope, tools, target root, and the wizard
|
|
4356
|
+
auto-launch decision. Writes nothing, spawns nothing. The wizard
|
|
4357
|
+
line is shown per Council Tier 3 § 10 user-requirement carve-out.
|
|
4358
|
+
"""
|
|
4359
|
+
target = Path(
|
|
4360
|
+
opts.custom_path or opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
|
|
4361
|
+
).resolve()
|
|
4362
|
+
will_launch, why_not = _wizard_should_launch(opts)
|
|
4363
|
+
print()
|
|
4364
|
+
print("[dry-run] Plan summary — no files written, no subprocesses spawned:")
|
|
4365
|
+
print(f" profile: {opts.profile}")
|
|
4366
|
+
print(f" user-type: {opts.user_type or '(none)'}")
|
|
4367
|
+
print(f" scope: {opts.scope or ('global' if opts.global_install else 'auto')}")
|
|
4368
|
+
print(f" tools: {opts.tools or 'all'}")
|
|
4369
|
+
print(f" target: {target}")
|
|
4370
|
+
print(f" minimal: {opts.minimal}")
|
|
4371
|
+
print(f" force: {opts.force}")
|
|
4372
|
+
print(f" offline: {opts.offline}")
|
|
4373
|
+
if will_launch:
|
|
4374
|
+
print(" wizard: Would auto-launch (pass --no-ui to suppress).")
|
|
4375
|
+
else:
|
|
4376
|
+
print(f" wizard: Suppressed ({why_not}).")
|
|
4377
|
+
print()
|
|
4378
|
+
return 0
|
|
4379
|
+
|
|
4380
|
+
|
|
3630
4381
|
# --- Main ---
|
|
3631
4382
|
|
|
4383
|
+
def _apply_payload_preview(payload: dict[str, Any], opts: argparse.Namespace) -> int:
|
|
4384
|
+
"""Render a Phase 1.5 dry-run preview for a WizardApplyPayload.
|
|
4385
|
+
|
|
4386
|
+
Reads schema_version, lists tools / packs / settings keys, and
|
|
4387
|
+
exits 0 without spawning. Used by the wizard `/api/v1/wizard/apply`
|
|
4388
|
+
bridge to surface the apply diff before the maintainer commits.
|
|
4389
|
+
"""
|
|
4390
|
+
schema_version = payload.get("schema_version", "<missing>")
|
|
4391
|
+
target = Path(
|
|
4392
|
+
opts.custom_path or opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()
|
|
4393
|
+
).resolve()
|
|
4394
|
+
print()
|
|
4395
|
+
print("[apply-payload] Plan summary — no files written, no subprocesses spawned:")
|
|
4396
|
+
print(f" schema: {schema_version}")
|
|
4397
|
+
if schema_version == "wizard-v2":
|
|
4398
|
+
tools = payload.get("tools") or []
|
|
4399
|
+
packs = payload.get("packs") or []
|
|
4400
|
+
settings = payload.get("settings") or {}
|
|
4401
|
+
scope_to_project = bool(payload.get("scope_to_project_only", False))
|
|
4402
|
+
print(f" tools: {','.join(tools) if tools else '(none)'}")
|
|
4403
|
+
print(f" packs: {','.join(packs) if packs else '(base)'}")
|
|
4404
|
+
print(f" settings: {len(settings)} top-level key(s)")
|
|
4405
|
+
print(f" scope: {'project' if scope_to_project else 'global'}")
|
|
4406
|
+
elif schema_version == "installer-v1":
|
|
4407
|
+
ai_tools = payload.get("ai_tools") or []
|
|
4408
|
+
configs = payload.get("configs") or {}
|
|
4409
|
+
print(f" ai_tools: {','.join(ai_tools) if ai_tools else '(none)'}")
|
|
4410
|
+
print(f" configs: {len(configs)} tool config(s)")
|
|
4411
|
+
else:
|
|
4412
|
+
print(f" error: unsupported schema_version: {schema_version!r}")
|
|
4413
|
+
print()
|
|
4414
|
+
return 2
|
|
4415
|
+
print(f" target: {target}")
|
|
4416
|
+
print(f" dry_run: {bool(payload.get('dry_run', opts.dry_run))}")
|
|
4417
|
+
print()
|
|
4418
|
+
return 0
|
|
4419
|
+
|
|
4420
|
+
|
|
3632
4421
|
def main(argv: list[str]) -> int:
|
|
3633
4422
|
global QUIET
|
|
3634
4423
|
|
|
3635
4424
|
opts = parse_options(argv)
|
|
3636
4425
|
QUIET = opts.quiet
|
|
3637
4426
|
|
|
4427
|
+
# road-to-global-only-install § Phase 1.5 — Wizard Apply bridge.
|
|
4428
|
+
# When --apply-payload <path> is supplied, read the WizardApplyPayload
|
|
4429
|
+
# JSON, validate schema_version, and (for dry-run) print a preview
|
|
4430
|
+
# block. The bridge calls install.py with `--dry-run` set, so the
|
|
4431
|
+
# short-circuit below this block keeps the run side-effect-free.
|
|
4432
|
+
if getattr(opts, "apply_payload", None):
|
|
4433
|
+
payload_path = Path(opts.apply_payload).resolve()
|
|
4434
|
+
if not payload_path.is_file():
|
|
4435
|
+
fail(f"--apply-payload path not found: {payload_path}")
|
|
4436
|
+
try:
|
|
4437
|
+
payload = json.loads(payload_path.read_text(encoding="utf-8"))
|
|
4438
|
+
except json.JSONDecodeError as exc:
|
|
4439
|
+
fail(f"--apply-payload JSON parse error: {exc}")
|
|
4440
|
+
if not isinstance(payload, dict):
|
|
4441
|
+
fail("--apply-payload root must be a JSON object")
|
|
4442
|
+
schema_version = payload.get("schema_version")
|
|
4443
|
+
if schema_version not in ("wizard-v2", "installer-v1"):
|
|
4444
|
+
fail(
|
|
4445
|
+
f"--apply-payload schema_version must be 'wizard-v2' or "
|
|
4446
|
+
f"'installer-v1', got {schema_version!r}"
|
|
4447
|
+
)
|
|
4448
|
+
# Translate payload → opts so the dry-run / real-install path
|
|
4449
|
+
# downstream sees the same shape it would from CLI flags. Only
|
|
4450
|
+
# dry-run preview is wired in Phase 1.5; real apply lands in a
|
|
4451
|
+
# follow-up minor (kill-switch via package version per D7).
|
|
4452
|
+
if schema_version == "wizard-v2":
|
|
4453
|
+
tools = payload.get("tools") or []
|
|
4454
|
+
if isinstance(tools, list) and tools:
|
|
4455
|
+
opts.tools = ",".join(t for t in tools if isinstance(t, str))
|
|
4456
|
+
if bool(payload.get("scope_to_project_only", False)):
|
|
4457
|
+
opts.scope = "project"
|
|
4458
|
+
else:
|
|
4459
|
+
opts.scope = "global"
|
|
4460
|
+
elif schema_version == "installer-v1":
|
|
4461
|
+
ai_tools = payload.get("ai_tools") or []
|
|
4462
|
+
if isinstance(ai_tools, list) and ai_tools:
|
|
4463
|
+
opts.tools = ",".join(t for t in ai_tools if isinstance(t, str))
|
|
4464
|
+
# Payload dry_run wins over CLI when explicitly set true.
|
|
4465
|
+
if bool(payload.get("dry_run", False)):
|
|
4466
|
+
opts.dry_run = True
|
|
4467
|
+
if opts.dry_run:
|
|
4468
|
+
return _apply_payload_preview(payload, opts)
|
|
4469
|
+
# Real-apply path through the payload bridge is gated by the
|
|
4470
|
+
# follow-up minor (Phase 1.9). Until then, fail loudly so the
|
|
4471
|
+
# caller knows they need --dry-run.
|
|
4472
|
+
fail(
|
|
4473
|
+
"--apply-payload without --dry-run is not yet wired "
|
|
4474
|
+
"(road-to-global-only-install § Phase 1.5 ships dry-run only)."
|
|
4475
|
+
)
|
|
4476
|
+
|
|
3638
4477
|
# --offline: propagate via env so child subprocesses (versions /
|
|
3639
4478
|
# update / check_update_banner) honor the air-gap guarantee
|
|
3640
4479
|
# without each one needing its own flag. AGENT_CONFIG_NO_UPDATE_CHECK
|
|
@@ -3647,6 +4486,24 @@ def main(argv: list[str]) -> int:
|
|
|
3647
4486
|
if opts.profile not in SUPPORTED_PROFILES:
|
|
3648
4487
|
fail(f"Unsupported profile: {opts.profile}. Supported: {', '.join(SUPPORTED_PROFILES)}")
|
|
3649
4488
|
|
|
4489
|
+
# Dry-run short-circuit (Council Tier 2 § 9): print a plan summary
|
|
4490
|
+
# and exit 0 before any filesystem write or subprocess spawn.
|
|
4491
|
+
# Distinct from the internal `--dry-run` strings passed to
|
|
4492
|
+
# alias-resolution sub-invocations elsewhere in this file.
|
|
4493
|
+
if getattr(opts, "dry_run", False):
|
|
4494
|
+
return _dry_run_summary(opts)
|
|
4495
|
+
|
|
4496
|
+
# Wizard auto-launch decision banner (Council Tier 2 § 8): print
|
|
4497
|
+
# the gate verdict BEFORE the install runs so the user knows what
|
|
4498
|
+
# will happen at the tail without having to wait through every
|
|
4499
|
+
# bridge write to find out.
|
|
4500
|
+
will_launch, why_not = _wizard_should_launch(opts)
|
|
4501
|
+
if will_launch:
|
|
4502
|
+
if not QUIET:
|
|
4503
|
+
info("Setup wizard will launch automatically after install.")
|
|
4504
|
+
elif not QUIET:
|
|
4505
|
+
info(f"Setup wizard auto-launch disabled ({why_not}).")
|
|
4506
|
+
|
|
3650
4507
|
# Minimal-init short-circuit (Step 7 Phase 2): bypass scope
|
|
3651
4508
|
# detection, conflict policy, and the full bridge install. Writes
|
|
3652
4509
|
# only the project-local override layer (agents/.gitkeep +
|
|
@@ -3674,6 +4531,7 @@ def main(argv: list[str]) -> int:
|
|
|
3674
4531
|
detected, detect_reason = detect_scope(detect_root)
|
|
3675
4532
|
custom_path: Path | None = Path(opts.custom_path).resolve() if opts.custom_path else None
|
|
3676
4533
|
scope = _resolve_scope(opts, detected, detect_reason, custom_path)
|
|
4534
|
+
_enforce_consumer_global_only(scope)
|
|
3677
4535
|
|
|
3678
4536
|
# Scope validation runs before filesystem / package detection so
|
|
3679
4537
|
# --tools=X / --scope conflicts fail fast with a directive error
|
|
@@ -3693,6 +4551,14 @@ def main(argv: list[str]) -> int:
|
|
|
3693
4551
|
|
|
3694
4552
|
try:
|
|
3695
4553
|
if scope == "global":
|
|
4554
|
+
# Phase 5.2 — first-run hook: when legacy artefacts live in the
|
|
4555
|
+
# project tree, prompt before laying down the global surface so
|
|
4556
|
+
# the user is not left with a dual-stack install.
|
|
4557
|
+
artefacts = _detect_legacy_for_migration(detect_root)
|
|
4558
|
+
if artefacts and _prompt_migrate_to_global(detect_root, artefacts):
|
|
4559
|
+
rc = _run_migrate_to_global(detect_root)
|
|
4560
|
+
if rc != 0:
|
|
4561
|
+
return rc
|
|
3696
4562
|
# Pass detect_root so the manifest refresh runs when --global is
|
|
3697
4563
|
# invoked from within a project tree (ADR-008 Phase 3.2).
|
|
3698
4564
|
return install_global(parsed_tools, opts.force, project_root=detect_root)
|
|
@@ -3861,6 +4727,15 @@ def _main_project_install(
|
|
|
3861
4727
|
else:
|
|
3862
4728
|
print(" Re-run complete. Walkthrough: https://github.com/event4u-app/agent-config/blob/main/docs/getting-started.md")
|
|
3863
4729
|
print()
|
|
4730
|
+
|
|
4731
|
+
# Wizard auto-launch (Phase 6 follow-up). Runs after the success
|
|
4732
|
+
# banner so the user sees install completion even if the wizard
|
|
4733
|
+
# boot times out. Gate was already evaluated at the top of main()
|
|
4734
|
+
# for the pre-install banner; re-check here so the supervisor
|
|
4735
|
+
# logic stays the single source of truth.
|
|
4736
|
+
will_launch, _ = _wizard_should_launch(opts)
|
|
4737
|
+
if will_launch:
|
|
4738
|
+
return _wizard_spawn(project_root)
|
|
3864
4739
|
return 0
|
|
3865
4740
|
|
|
3866
4741
|
|