diff --git a/INSTALL.md b/INSTALL.md index 8e90bec..d98f631 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,153 +1,97 @@ -# Agent 安装入口:NocoBase CRM MCP +# 安装 NocoBase CRM MCP -这是一份供任意终端型 Agent 执行、也供用户审阅的安装协议。它不依赖 Codex、Claude Code、Cursor、OpenCode 或其他单一产品的插件格式。MCP 是可移植的功能层;Agent 只需把最后生成的 stdio 启动配置转换成自己客户端支持的格式。 +这是一份供终端型 AI Agent 执行的通用安装协议,不依赖 Codex、Claude Code、Cursor、OpenCode 或其他特定产品。用户只需要提供本链接、确认一次本机变更,并在隐藏提示中输入自己的 NocoBase API Key。 -如果你是收到本链接的 Agent:请完整阅读本文后再执行命令,按顺序持续工作到“完成条件”全部满足。不要把本文当成需要原样转发给用户的普通说明;你应根据操作系统、Shell、已有软件和当前 Agent 客户端自适应执行,并向用户报告关键检查点。 +如果你是安装 Agent,请直接完成下面的流程,不要把命令转交给用户逐条尝试。只有在需要用户确认安装内容、提供 NocoBase 地址或在隐藏提示中粘贴 Key 时才打断用户。 -## 不可变安全规则 +## 完成目标 -1. 只安装 [`install-manifest.json`](install-manifest.json) 指定的仓库和 `releaseRef`,不得默认安装 `master`、`main`、开发分支或其他未固定版本。 -2. 不使用 `curl | sh`、`wget | sh` 或同类远程脚本管道。缺少全局依赖时,先说明来源、版本、影响范围并取得用户同意,再采用该工具的官方安装方式。 -3. 不使用 `sudo`,不删除或覆盖未知目录,不丢弃已有 Git 改动。目标目录已存在时先识别它;不是本项目或工作区不干净时停止覆盖并向用户说明。 -4. 认证默认使用用户自己的个人 API Key(Authorize Key),不使用账号密码。Agent 可以引导用户在 NocoBase 页面创建 Key,但不得要求用户把 Key 发到聊天;Key 只能由用户粘贴到本机、用户可控、隐藏回显的安全输入提示中。 -5. 默认只读。除非用户明确要求且管理员已确认 NocoBase ACL,否则不得启用 `--enable-writes`、附件上传或原始数据输出。 -6. 写入 Agent/MCP 客户端配置前,显示目标文件和将新增的 server 名称,创建可恢复备份,并只合并 `nocobase-crm` 条目;不得覆盖其他 MCP 配置。 -7. 不把本机 MCP 端口暴露到局域网或公网。通用安装默认使用 stdio,不创建后台服务,也不生成共享业务凭证。 +安装完成时必须同时满足: -## 安装输入 +- 使用固定 Release `v0.2.0`,不是开发分支; +- MCP 在用户本机以 stdio 运行,默认只读; +- NocoBase 认证属于当前用户自己的个人 Key 和业务角色; +- 当前 MCP 客户端已注册 `nocobase-crm`; +- `crm_health` 返回 `ok=true`。 -从用户或当前上下文确定以下内容;已有明确答案时不要重复提问: +## 不可违反的安全规则 -- NocoBase 应用 URL,例如 `https://nocobase.example`。安装器会规范化为以 `/api` 结尾;远程环境必须使用 HTTPS。 -- 安装目录。使用该操作系统的普通用户应用/数据目录作为默认值,并在写入前告知用户;不要默认放在系统目录。 -- 当前 Agent/MCP 客户端的名称及其本机 MCP 配置位置。 -- 功能模式。未明确选择时必须使用“只读”。 +- 不索取账号密码,不使用共享服务账号或管理员 Key。 +- 不让用户把 Key 发到聊天、命令参数、环境变量或临时文件。 +- Key 只能由用户粘贴到 `nb` CLI 的本机隐藏 TTY 提示。 +- 不使用 `sudo`,不执行 `curl | sh` / `wget | sh`。 +- 写 MCP 客户端配置前必须展示目标路径、创建备份并结构化合并,不能覆盖其他 server。 +- 除非用户明确要求且管理员已确认 ACL,否则不启用写入、上传或原始数据。 +- 默认不创建后台服务、不监听端口、不暴露到局域网或公网。 -不要要求用户在 Agent 对话中提供 NocoBase Key、密码、Token、Cookie 或 `~/.nocobase` 目录。Key 只进入 `nb` CLI 自己的本机隐藏提示。 +## 一次安装流程 -## 执行协议 +### 1. 确认输入和访问权限 -### 1. 读取并锁定发布版本 +从用户或上下文取得: -读取同版本的 `install-manifest.json`。本版本的预期来源是: +- NocoBase 应用 URL,例如 `https://nocobase.example`; +- 普通用户安装目录; +- 当前 Agent/MCP 客户端及其配置路径。 -```text -repository: https://github.com/Donaldttt/nocobase-mcp.git -releaseRef: v0.2.0 -``` +本项目暂时通过 Private GitHub 仓库分发。当前 Agent 必须已经连接一个对 `Donaldttt/nocobase-mcp` 有只读权限的 GitHub 账号;无权读取时停止,不得退回 `master`。 -若 `v0.2.0` 尚不存在,或 Agent 无权读取私有仓库,停止安装并清楚报告“发布物/访问权限尚未就绪”,不得退回开发分支。面向普通外部用户发布时,安装方必须能匿名读取本页和发布物;私有分发则需要用户已连接有权访问该仓库的 GitHub 账号。 +### 2. 一次性说明本机变更 -首次安装使用等效于以下语义的 Git 命令,并根据当前 Shell 正确转义路径: +检查 Git、uv、Python 3.12+ 和 NocoBase `nb` CLI 2.1+。若缺失,向用户一次性说明将安装的工具、官方来源和位置,并取得一次确认后再处理。只有缺少 `nb` 时才可能额外需要 Node.js/npm;官方 CLI 包为 `@nocobase/cli`。 + +用户安装会留下:固定版本仓库副本、项目 `.venv`、不含 Key 的 `config.json`、`nb` CLI 用户登录环境、MCP 客户端配置和配置备份。不会默认创建 systemd 服务或 HTTP Token。 + +### 3. 取得固定版本 + +安全克隆并验证 Release: ```bash git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git git -C describe --tags --exact-match HEAD ``` -第二条必须精确输出 `v0.2.0`。已有安装的更新不得直接 `git pull` 开发分支;先保存当前版本和 `config.json`,确认工作区干净,再获取并切换到用户指定的新 Release Tag。 +第二条必须精确输出 `v0.2.0`。目标目录已存在但不是本项目、包含未提交修改或版本不明确时停止覆盖。 -### 2. 环境检查与确定性准备 +### 4. 只运行一个安装命令 -需要:Git、`uv`、Python 3.12+、NocoBase `nb` CLI 2.1+。Git 只用于取得和校验发布版本;`uv` 用于创建锁定的项目环境以及后续诊断/更新。若机器尚未安装 `nb`,安装官方 CLI 还会条件性需要 Node.js 与 npm。先运行只读检查: +先引导用户在 NocoBase 个人设置中创建一个可撤销、有合理有效期、绑定本人普通业务角色的个人 API Key。不要让用户把 Key 发给你。 -```bash -uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root inspect --json -``` - -若 `uv`、`nb` 或安装 `nb` 所需的 Node.js/npm 不存在,先向用户说明来源、安装位置和影响范围并取得同意。NocoBase CLI 当前官方包入口是 `npm install -g @nocobase/cli`;安装后必须重新运行检查,不要通过修改 PATH 输出伪造成功。 - -然后运行幂等准备步骤: - -```bash -uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root prepare --json -``` - -该脚本只会校验项目文件、在缺失时从示例创建 `config.json`,并把 `uv.lock` 中的**运行时依赖**同步到项目自己的 `.venv`。它使用 `--no-dev`,不会给终端用户安装 `mypy`、`ruff`、`pip-audit` 或其传递依赖。已有 `config.json` 会被保留。 - -安装会在用户电脑留下以下内容:固定 Release 的仓库副本及浅层 `.git`、项目 `.venv`、不含 Key 的 `config.json`、由 `nb` CLI 管理的个人登录环境,以及合并后的 MCP 客户端配置和原配置备份。`uv` 可能保留下载缓存,缓存不是 MCP 运行所必需;安装器不会自动删除用户的共享缓存。默认 stdio 流程不会创建 systemd 服务、监听端口或 `.runtime` Token。 - -### 3. 连接用户自己的 NocoBase 账号 - -先引导用户在目标 NocoBase 网页中打开头像或个人设置,寻找当前版本中名为 `API Keys`、`Access Tokens`、`访问令牌` 或类似名称的入口。让用户创建一个: - -- 绑定用户本人,而不是共享服务账号; -- 只使用其正常业务角色,不选择管理员/root 角色; -- 有合理过期时间、可单独撤销; -- 仅用于这台电脑上的 MCP。 - -如果用户看不到个人 Key 功能,Agent 应提示联系 NocoBase 管理员开通或代为签发绑定该用户普通业务角色的 Key;不得改用账号密码或共享管理员 Key。 - -取得 Key 后,Agent 必须在**用户能直接控制输入的本机终端**中启动以下流程,而不是把命令交给用户手动尝试: +然后在用户可直接控制输入的本机终端中运行: ```bash uv run --no-project --python 3.12 python /scripts/agent_install.py \ - --project-root connect-key \ + --project-root install \ --api-base-url --env-name nb-mcp --json ``` -安装器会自动: +这个命令会一次完成运行环境准备、隐藏 Key 登录、配置写入、doctor 检查和通用 MCP 客户端配置生成。用户只在 `nb` 的隐藏提示出现时粘贴 Key;Agent 不读取输入。 -1. 规范化并验证 HTTPS URL; -2. 脱敏检查已有环境,拒绝覆盖指向其他服务器的同名环境; -3. 调用 `nb env add/auth --auth-type token`,但故意不传 `--access-token`,让 `nb` 在 TTY 内隐藏提示; -4. 由用户本人把 Key 粘贴到这个本机提示中,Agent 不读取输入; -5. 自动执行环境读回、把选定环境写入 `config.json`,并在修改前保存 `config.json.pre-agent-install.bak`。 +若终端不支持用户私密输入,命令会在写入前停止。此时报告缺少安全 TTY,不得让用户改为在聊天中发送 Key。 -若环境名冲突,安装器会给出新名称建议;Agent 使用建议名称重启同一流程,不覆盖旧环境。若当前 Agent 无法提供用户可直接输入的本机 TTY,必须停止并报告“缺少安全 Key 输入通道”;不得要求用户把 Key 粘到聊天、命令参数、环境变量或临时文件中,也不得把 `nb` 命令交给用户反复尝试。 +### 5. 自动完成客户端注册和验证 -Key 过期或撤销后,Agent 重新启动 `connect-key`,用户在新的隐私提示中输入新 Key。不得退回共享凭证。 +读取命令返回的 `clientConfig`: -### 4. 注册到当前 Agent/MCP 客户端 +1. 展示当前客户端配置文件路径和将新增的 `nocobase-crm` 条目; +2. 创建可恢复备份; +3. 结构化合并该 server,保留所有其他配置; +4. 按客户端能力重载配置或重启客户端; +5. 调用 `crm_health`,要求 `ok=true`。 -生成不含 NocoBase Token 的通用配置: +只有完成以上验证后才能向用户报告成功。最终只报告安装目录、Release、NocoBase 环境名、客户端配置备份位置和启用模式,不显示任何 Token。 -```bash -uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root client-config -``` +## 默认模式与可选写入 -输出采用常见的 `mcpServers` JSON 形状,包含项目 `.venv` 中 Python、项目配置和 `nb` 的绝对路径。MCP 客户端直接启动已准备好的虚拟环境,不在每次调用时经过 `uv` 或同步依赖。Agent 应查明自己客户端当前版本支持的 MCP 配置格式,将其中的 `command`、`args`、`env` 等价映射过去。 +未明确选择时必须保持只读。只有用户明确要求项目创建/更新,且管理员确认该用户角色具有相应 NocoBase ACL 时,才在同一个安装命令末尾加 `--enable-writes`。这仍不会启用附件上传、删除或原始数据。 -写入前: +## 故障处理 -1. 向用户显示目标客户端配置文件路径和新增的 `nocobase-crm` 条目。 -2. 备份原文件;解析后做结构化合并,不能用字符串替换或整文件覆盖。 -3. 保留所有其他 server、设置和注释(客户端格式允许时)。 -4. 按客户端要求重载 MCP 配置或提醒用户重启客户端。 +不要让普通用户手动尝试 `nb` 或拆分安装子命令。由 Agent 根据结构化错误处理;需要详细恢复、更新或高级 HTTP 部署时,阅读同版本的 [`docs/LOCAL_DELIVERY.md`](docs/LOCAL_DELIVERY.md)。Key 过期或撤销时,重新运行同一个 `install` 命令,让用户在新的隐藏提示中输入新 Key。 -只有用户明确要求项目创建/更新,且其管理员确认对应角色 ACL 后,才可重新生成含写工具的配置: - -```bash -uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root client-config --enable-writes -``` - -这仍不启用上传、删除或原始数据。附件上传属于单独的 Linux 本机 HTTP 高级部署,不是通用安装流程的一部分。 - -### 5. 验证 - -先检查本机安装,不读取 NocoBase 秘密: - -```bash -uv run --no-project --python 3.12 python /scripts/agent_install.py --project-root doctor --json -``` - -然后通过已注册的 MCP 调用 `crm_health`。只有同时满足以下条件才可向用户报告安装完成: - -- `doctor` 返回 `ok: true`; -- `connect-key` 返回 `ok: true`、`authType: token`,且目标 URL 正确; -- 客户端已发现 `nocobase-crm`; -- `crm_health` 返回 `ok: true`,不是 `nb_auth_failed`、`NO_PERMISSIONS` 或其他结构化错误; -- 默认状态仍是只读、uploads disabled、raw data disabled。 - -最后报告安装目录、Release Tag、NocoBase 环境名、客户端配置备份位置、启用的能力以及一条最小使用示例。不要报告或回显任何 Token。 - -## 给用户的最短用法 - -普通情况下,用户只需把当前**不可变 Release 版本**的 `INSTALL.md` 链接发给具备本机终端和文件权限的 Agent,并说: +## 用户可发送给 Agent 的最短指令 ```text -请完整阅读这个安装协议,根据我的系统和当前 Agent 客户端自适应安装。默认只读;认证使用我本人的个人 API Key,不使用账号密码。请引导我在 NocoBase 获取 Key,并只让我在本机隐藏提示中粘贴;不要让我把 Key 发到聊天,也不要让我手动尝试 nb 命令。安装后必须通过 doctor 和 crm_health: - +请按这个协议帮我完成本机安装。默认只读,使用我自己的 NocoBase API Key;只让我在本机隐藏提示中输入 Key,不要让我手动执行安装或 nb 命令。安装完成前必须验证 crm_health: +<固定版本 INSTALL.md 链接> ``` - -如果 Agent 只能聊天、不能执行本机命令或写 MCP 配置,它应说明能力限制并按本文生成逐步人工安装指令,而不能声称已经安装。 diff --git a/README.md b/README.md index 857bfc8..6104f01 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ The default stdio server is read-only. Add `--enable-writes` for fixed-schema pr ## Configure -The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then starts this helper in a user-controlled local terminal: +The supported end-user flow uses a personal NocoBase API Key (Authorize Key), not a username/password or a shared service credential. The installing Agent asks the user to create a revocable Key in NocoBase, then runs the complete guided installation once in a user-controlled local terminal: ```bash uv run --no-project --python 3.12 python scripts/agent_install.py \ - --project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json + --project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json ``` The user pastes the Key only into `nb` CLI's private TTY prompt. The helper never accepts the Key as an argument, environment variable, chat value, file, captured output, or return value. Do not copy another user's Key or `nb` configuration directory. The Key must represent that user's normal business identity/role, so NocoBase can apply the intended roles, row-level filters, and field permissions to every operation. @@ -188,10 +188,10 @@ Queries and writes use the saved `nb-mcp` personal Key login. For multipart uplo First-install flow: ```bash -# The Agent first runs connect-key in a user-controlled TTY. The user pastes a -# personal NocoBase API Key only into the hidden prompt; it never enters argv. +# The Agent runs the guided installer in a user-controlled TTY. The user pastes +# a personal NocoBase API Key only into the hidden prompt; it never enters argv. uv run --no-project --python 3.12 python scripts/agent_install.py \ - --project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json + --project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json # Create and start the private, loopback-only user service. bash scripts/mcp_service.sh start diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e7efc44..58c0f8a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -17,7 +17,7 @@ The local `nb` environment is the identity boundary. The MCP does not keep a sha ## Bootstrap and advanced deployment -- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`, so `inspect` and secure Key connection can run before project dependencies are synchronized. +- `scripts/agent_install.py` is a compatibility entrypoint. Its standard-library-only implementation lives in `scripts/agent_installer/`; `flow.py` composes the public one-command `install` journey, while the lower-level inspect, prepare, authentication, diagnostics, and client-config functions remain independently testable recovery primitives. - `scripts/manage_mcp_service.py` is the compatibility entrypoint for optional Linux loopback HTTP mode. Configuration serialization, runtime operations, and CLI parsing live in separate `mcp_service_*` modules. Default installation remains stdio, read-only, without a background service, listener port, uploads, raw rows, or shared credentials. diff --git a/docs/LOCAL_DELIVERY.md b/docs/LOCAL_DELIVERY.md index 17b5bb8..53f6f6b 100644 --- a/docs/LOCAL_DELIVERY.md +++ b/docs/LOCAL_DELIVERY.md @@ -28,23 +28,21 @@ MCP 不保存公共业务账号,也不需要部署一个多人共用的远程 这个通用流程默认使用 stdio,因此不需要后台服务或本机 MCP Bearer Token。Linux systemd/HTTP 方式只用于确有附件上传需求的高级场景。 -## 首次安装 +## 首次安装与统一引导 ```bash -git clone https://github.com/Donaldttt/nocobase-mcp.git +git clone --branch v0.2.0 --depth 1 https://github.com/Donaldttt/nocobase-mcp.git cd nocobase-mcp -uv sync --locked --no-dev -cp config.example.json config.json ``` -`config.json` 中的 `env` 默认是 `nb-mcp`。正式终端用户不应手动尝试 `nb env add/auth`;安装 Agent 在用户可直接控制的本机终端中运行: +正式终端用户不应手动尝试依赖同步或 `nb env add/auth`。安装 Agent 在用户可直接控制的本机终端中只运行统一安装命令: ```bash uv run --no-project --python 3.12 python scripts/agent_install.py \ - --project-root . connect-key --api-base-url https://nocobase.example --env-name nb-mcp --json + --project-root . install --api-base-url https://nocobase.example --env-name nb-mcp --json ``` -Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。 +该命令依次准备运行环境、连接个人 Key、运行 doctor,并返回通用 `clientConfig`。Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有效期、绑定本人普通业务角色的 API Key。用户只把 Key 粘贴到 `nb` CLI 的隐藏 TTY 提示;脚本不接收或回显 Key,也不会把它放入命令参数、聊天、环境变量、临时文件或 MCP 配置。不要分发共享管理员 Key,也不要复制其他人的 `nb` 配置目录。 先进行健康检查: @@ -77,7 +75,7 @@ Agent 先引导用户在 NocoBase 个人设置中创建可撤销、有合理有 } ``` -不要手写猜测虚拟环境路径;优先使用 `scripts/agent_install.py client-config` 的输出。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。 +不要手写猜测虚拟环境路径;优先使用统一 `install` 结果中的 `clientConfig`。上例是 POSIX 路径,Windows 输出会自动使用 `.venv\\Scripts\\python.exe`。`inspect`、`prepare`、`connect-key`、`client-config` 和 `doctor` 子命令仅为排障和旧流程兼容保留,不应让普通用户逐条运行。 默认只读。如需项目创建和更新,在参数末尾加入 `--enable-writes`。这些写工具只允许预先定义的项目字段,不提供删除或任意资源访问能力。 diff --git a/install-manifest.json b/install-manifest.json index 119836b..864893f 100644 --- a/install-manifest.json +++ b/install-manifest.json @@ -45,6 +45,7 @@ "entrypoints": { "guide": "INSTALL.md", "helper": "scripts/agent_install.py", + "guidedCommand": "install", "configTemplate": "config.example.json" }, "securityDefaults": { diff --git a/scripts/agent_install.py b/scripts/agent_install.py index 88db4c7..82e6737 100644 --- a/scripts/agent_install.py +++ b/scripts/agent_install.py @@ -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", diff --git a/scripts/agent_installer/__init__.py b/scripts/agent_installer/__init__.py index c5b5944..45c6db6 100644 --- a/scripts/agent_installer/__init__.py +++ b/scripts/agent_installer/__init__.py @@ -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", diff --git a/scripts/agent_installer/cli.py b/scripts/agent_installer/cli.py index 1397ab6..49d9048 100644 --- a/scripts/agent_installer/cli.py +++ b/scripts/agent_installer/cli.py @@ -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) diff --git a/scripts/agent_installer/flow.py b/scripts/agent_installer/flow.py new file mode 100644 index 0000000..70246f8 --- /dev/null +++ b/scripts/agent_installer/flow.py @@ -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"] diff --git a/tests/test_agent_install.py b/tests/test_agent_install.py index d061f44..7378245 100644 --- a/tests/test_agent_install.py +++ b/tests/test_agent_install.py @@ -52,6 +52,88 @@ class AgentInstallTests(unittest.TestCase): self.assertFalse(checks["config"]["ok"]) self.assertFalse(checks["config"]["required"]) + def test_guided_install_composes_runtime_key_doctor_and_client_config(self) -> None: + calls = [] + info_reads = 0 + + def runner(command, **kwargs): + nonlocal info_reads + calls.append((command, kwargs)) + if command[1:4] == ["env", "info", "nb-mcp"]: + info_reads += 1 + if info_reads == 1: + return subprocess.CompletedProcess(command, 1, stdout="", stderr='Env "nb-mcp" is not configured') + return subprocess.CompletedProcess( + command, + 0, + stdout=json.dumps({"api": {"apiBaseUrl": "https://nb.example/api", "auth": {"type": "token"}}}), + stderr="", + ) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + make_project(root) + + result = agent_install.install_project( + root, + "https://nb.example", + runner=runner, + which=fake_which, + python_version=(3, 12, 4), + stdin_isatty=lambda: True, + ) + + configured = json.loads((root / "config.json").read_text(encoding="utf-8")) + + commands = [command for command, _kwargs in calls] + self.assertTrue(result["ok"]) + self.assertEqual(result["status"], "ready-for-client-registration") + self.assertEqual(result["mode"], "read-only") + self.assertEqual(result["prepared"]["dependencyProfile"], "locked-runtime-only") + self.assertEqual(result["authentication"]["keyExposure"], "private-tty-only") + self.assertEqual(configured["env"], "nb-mcp") + self.assertEqual( + result["clientConfig"]["mcpServers"]["nocobase-crm"]["command"], + str(root.resolve() / ".venv" / "bin" / "python"), + ) + self.assertEqual(commands[0][1:5], ["sync", "--locked", "--no-dev", "--python"]) + self.assertTrue(any("crm_health" in action for action in result["remainingActions"])) + self.assertNotIn("access-token", json.dumps(result)) + + def test_guided_install_refuses_non_tty_before_writing(self) -> None: + calls = [] + + def runner(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + make_project(root) + + with self.assertRaisesRegex(agent_install.InstallerError, "user-controlled local terminal"): + agent_install.install_project( + root, + "https://nb.example", + runner=runner, + which=fake_which, + python_version=(3, 12, 4), + stdin_isatty=lambda: False, + ) + + self.assertFalse((root / "config.json").exists()) + + self.assertEqual(calls, []) + + def test_cli_exposes_one_guided_install_command(self) -> None: + args = agent_install.build_parser().parse_args(["install", "--api-base-url", "https://nb.example", "--json"]) + + self.assertEqual(args.command, "install") + self.assertEqual(args.env_name, "nb-mcp") + self.assertFalse(args.enable_writes) + self.assertTrue(args.as_json) + def test_prepare_is_idempotent_and_never_overwrites_existing_config(self) -> None: calls = [] @@ -291,6 +373,7 @@ class AgentInstallTests(unittest.TestCase): self.assertFalse(manifest["authentication"]["allowCommandLineSecret"]) self.assertEqual(manifest["runtime"]["launcher"], "project-venv-python") self.assertEqual(manifest["runtime"]["dependencyProfile"], "runtime-only-no-dev") + self.assertEqual(manifest["entrypoints"]["guidedCommand"], "install") self.assertFalse(manifest["installation"]["defaultBackgroundService"]) self.assertTrue(manifest["securityDefaults"]["readOnly"]) self.assertFalse(manifest["securityDefaults"]["uploads"])