feat: add guided one-command installer
This commit is contained in:
@@ -17,6 +17,7 @@ from agent_installer.common import (
|
||||
Which,
|
||||
)
|
||||
from agent_installer.diagnostics import doctor_project
|
||||
from agent_installer.flow import install_project
|
||||
from agent_installer.project import (
|
||||
inspect_project,
|
||||
prepare_project,
|
||||
@@ -38,6 +39,7 @@ __all__ = [
|
||||
"connect_with_personal_key",
|
||||
"doctor_project",
|
||||
"inspect_project",
|
||||
"install_project",
|
||||
"main",
|
||||
"normalize_api_base_url",
|
||||
"prepare_project",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from .auth import connect_with_personal_key, normalize_api_base_url
|
||||
from .cli import main
|
||||
from .diagnostics import doctor_project
|
||||
from .flow import install_project
|
||||
from .project import inspect_project, prepare_project, render_client_config, set_config_env
|
||||
|
||||
__all__ = [
|
||||
"connect_with_personal_key",
|
||||
"doctor_project",
|
||||
"inspect_project",
|
||||
"install_project",
|
||||
"main",
|
||||
"normalize_api_base_url",
|
||||
"prepare_project",
|
||||
|
||||
@@ -11,6 +11,7 @@ from .common import (
|
||||
json,
|
||||
)
|
||||
from .diagnostics import doctor_project
|
||||
from .flow import install_project
|
||||
from .project import inspect_project, prepare_project, render_client_config, set_config_env
|
||||
|
||||
|
||||
@@ -24,6 +25,11 @@ def _print_result(payload: dict[str, Any], *, as_json: bool) -> None:
|
||||
for check in payload.get("checks", []):
|
||||
marker = "PASS" if check["ok"] else ("FAIL" if check["required"] else "INFO")
|
||||
print(f"[{marker}] {check['name']}: {check['detail']}")
|
||||
if "status" in payload:
|
||||
print(f"Status: {payload['status']}")
|
||||
if "clientConfig" in payload:
|
||||
print("Client configuration:")
|
||||
print(json.dumps(payload["clientConfig"], ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -31,6 +37,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--project-root", type=Path, default=None)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
install = subparsers.add_parser("install", help="Run the complete guided local installation flow.")
|
||||
install.add_argument("--api-base-url", required=True)
|
||||
install.add_argument("--env-name", default=DEFAULT_ENV_NAME)
|
||||
install.add_argument("--enable-writes", action="store_true")
|
||||
install.add_argument("--json", action="store_true", dest="as_json")
|
||||
|
||||
for name in ("inspect", "prepare", "doctor"):
|
||||
command = subparsers.add_parser(name)
|
||||
command.add_argument("--json", action="store_true", dest="as_json")
|
||||
@@ -53,6 +65,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
if args.command == "install":
|
||||
payload = install_project(
|
||||
args.project_root,
|
||||
args.api_base_url,
|
||||
env_name=args.env_name,
|
||||
enable_writes=args.enable_writes,
|
||||
)
|
||||
_print_result(payload, as_json=args.as_json)
|
||||
return 0 if payload["ok"] else 1
|
||||
if args.command == "inspect":
|
||||
payload = inspect_project(args.project_root)
|
||||
_print_result(payload, as_json=args.as_json)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .auth import connect_with_personal_key, normalize_api_base_url
|
||||
from .common import (
|
||||
DEFAULT_ENV_NAME,
|
||||
ENV_NAME_PATTERN,
|
||||
SERVER_NAME,
|
||||
Any,
|
||||
Callable,
|
||||
InstallerError,
|
||||
Path,
|
||||
Runner,
|
||||
Which,
|
||||
_project_root,
|
||||
shutil,
|
||||
subprocess,
|
||||
sys,
|
||||
)
|
||||
from .diagnostics import doctor_project
|
||||
from .project import inspect_project, prepare_project, render_client_config
|
||||
|
||||
|
||||
def install_project(
|
||||
project_root: str | Path | None,
|
||||
api_base_url: str,
|
||||
*,
|
||||
env_name: str = DEFAULT_ENV_NAME,
|
||||
enable_writes: bool = False,
|
||||
runner: Runner = subprocess.run,
|
||||
which: Which = shutil.which,
|
||||
python_version: tuple[int, int, int] | None = None,
|
||||
stdin_isatty: Callable[[], bool] = sys.stdin.isatty,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare, authenticate, diagnose, and describe one local installation.
|
||||
|
||||
The returned client configuration is intentionally not written here: the
|
||||
installation Agent must merge it into its own client-specific format after
|
||||
showing the destination and creating a recoverable backup.
|
||||
"""
|
||||
|
||||
root = _project_root(project_root)
|
||||
normalized_url = normalize_api_base_url(api_base_url)
|
||||
if not ENV_NAME_PATTERN.fullmatch(env_name):
|
||||
raise InstallerError(
|
||||
"Environment name must start with a letter or digit and contain only letters, digits, dot, underscore, or dash"
|
||||
)
|
||||
if not stdin_isatty():
|
||||
raise InstallerError(
|
||||
"Secure API Key entry requires a user-controlled local terminal. "
|
||||
"Do not paste the Key into Agent chat or pass it with --access-token."
|
||||
)
|
||||
|
||||
inspection = inspect_project(root, which=which, python_version=python_version)
|
||||
failed_prerequisites = [check["detail"] for check in inspection["checks"] if check["required"] and not check["ok"]]
|
||||
if failed_prerequisites:
|
||||
raise InstallerError("Installation prerequisites are not ready: " + "; ".join(failed_prerequisites))
|
||||
|
||||
prepared = prepare_project(
|
||||
root,
|
||||
runner=runner,
|
||||
which=which,
|
||||
python_version=python_version,
|
||||
)
|
||||
authentication = connect_with_personal_key(
|
||||
root,
|
||||
normalized_url,
|
||||
env_name=env_name,
|
||||
runner=runner,
|
||||
which=which,
|
||||
stdin_isatty=stdin_isatty,
|
||||
)
|
||||
diagnosis = doctor_project(
|
||||
root,
|
||||
runner=runner,
|
||||
which=which,
|
||||
python_version=python_version,
|
||||
)
|
||||
client_config = render_client_config(root, enable_writes=enable_writes, which=which)
|
||||
local_ready = bool(diagnosis["ok"])
|
||||
return {
|
||||
"ok": local_ready,
|
||||
"status": "ready-for-client-registration" if local_ready else "local-checks-failed",
|
||||
"projectRoot": str(root),
|
||||
"mode": "project-writes" if enable_writes else "read-only",
|
||||
"prepared": {
|
||||
"environmentPath": prepared["environmentPath"],
|
||||
"dependencyProfile": prepared["dependencies"],
|
||||
"configCreated": prepared["configCreated"],
|
||||
},
|
||||
"authentication": authentication,
|
||||
"checks": diagnosis["checks"],
|
||||
"clientConfig": client_config,
|
||||
"clientRegistration": {
|
||||
"required": True,
|
||||
"serverName": SERVER_NAME,
|
||||
"backupRequired": True,
|
||||
"action": "merge the generated server entry, reload the client, then call crm_health",
|
||||
},
|
||||
"remainingActions": [
|
||||
"Merge clientConfig into the current MCP client without replacing other servers.",
|
||||
"Reload or restart the MCP client.",
|
||||
"Call crm_health and require ok=true before reporting installation complete.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["install_project"]
|
||||
Reference in New Issue
Block a user