diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cef46ac..49281e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,86 @@ jobs: - name: Repository checks run: npm run check + + bootstrap-linux: + name: Bootstrap / Linux + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + TEST_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 20.x + + - name: Run isolated bootstrap + shell: bash + run: | + set -euo pipefail + temp_home="$(mktemp -d)" + export HOME="$temp_home" + export JEV_MCP_CONFIG_HOME="$temp_home/.jev-mcp" + export JEV_MCP_RUNTIME_DIR="$temp_home/.jev-mcp/runtime" + export JEV_MCP_GIT_REF="$TEST_REF" + + bash scripts/install.sh cursor --skip-key + + test -f "$temp_home/.cursor/mcp.json" + test -f "$temp_home/.jev-mcp/runtime/dist/cli.js" + grep -q '"jev"' "$temp_home/.cursor/mcp.json" + grep -q 'dist/cli.js' "$temp_home/.cursor/mcp.json" + ! grep -q 'TYPESAFE_API_KEY' "$temp_home/.cursor/mcp.json" + ! test -f "$temp_home/.jev-mcp/.env" + + bootstrap-windows: + name: Bootstrap / Windows + runs-on: windows-latest + timeout-minutes: 12 + env: + TEST_REF: ${{ github.event.pull_request.head.ref || github.ref_name }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 20.x + + - name: Run isolated bootstrap + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $tempHome = Join-Path $env:RUNNER_TEMP "jev-mcp-user" + New-Item -ItemType Directory -Force -Path $tempHome | Out-Null + + $env:HOME = $tempHome + $env:USERPROFILE = $tempHome + $env:JEV_MCP_CONFIG_HOME = Join-Path $tempHome ".jev-mcp" + $env:JEV_MCP_RUNTIME_DIR = Join-Path $env:JEV_MCP_CONFIG_HOME "runtime" + $env:JEV_MCP_GIT_REF = $env:TEST_REF + + ./scripts/install.ps1 -Target cursor -SkipKey + + $config = Join-Path $tempHome ".cursor/mcp.json" + $cli = Join-Path $env:JEV_MCP_RUNTIME_DIR "dist/cli.js" + if (-not (Test-Path $config)) { throw "Cursor MCP config was not created." } + if (-not (Test-Path $cli)) { throw "Managed jev-mcp runtime was not built." } + + $text = Get-Content $config -Raw + $parsed = $text | ConvertFrom-Json + $entry = $parsed.mcpServers.jev + if ($null -eq $entry) { throw "Jev MCP entry missing." } + if ($entry.args[0] -ne $cli) { + throw "Durable runtime path mismatch: $($entry.args[0])" + } + if ($text -match 'TYPESAFE_API_KEY') { throw "Credential leaked into client config." } + if (Test-Path (Join-Path $env:JEV_MCP_CONFIG_HOME ".env")) { + throw "Skip-key smoke test unexpectedly created a credential file." + } diff --git a/CHANGELOG.md b/CHANGELOG.md index fcacd48..e3be11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable project changes are documented here. ## Unreleased +- Added cross-platform bootstrap installers that create/update a durable local + runtime at `~/.jev-mcp/runtime` instead of relying on npm Git-package + execution at every MCP startup. +- Added one-command setup for Codex, Claude Code, Kimi Code, ZCode, Cursor, + Gemini CLI, Windsurf-compatible config, generic `.agents`, and + project-scoped VS Code MCP configuration. +- Added a user-level `~/.jev-mcp/.env` credential store so GUI clients do not + need API keys embedded in MCP configuration. +- Added safe JSON config merging with local backups and absolute local + Node/runtime launch paths. +- Added installer unit tests, Linux/Windows bootstrap smoke checks, CLI smoke + checks, and dedicated client/install documentation. + - Redesigned the English and Chinese project homepages around quick onboarding, tool boundaries, privacy, and a clear host-model/Jev authority model. - Added quick-start, FAQ, troubleshooting, examples, support, governance, diff --git a/README.md b/README.md index 51b967f..1c7bef1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **Unofficial, community-maintained MCP server for TypeSafe AI Jev.** -[简体中文](README.zh-CN.md) · [Quick start](docs/QUICKSTART.md) · [Examples](examples/README.md) · [Security](SECURITY.md) · [FAQ](docs/FAQ.md) +[简体中文](README.zh-CN.md) · [One-click install](docs/INSTALLATION.md) · [Quick start](docs/QUICKSTART.md) · [Examples](examples/README.md) · [Security](SECURITY.md) · [FAQ](docs/FAQ.md) `jev-mcp` exposes TypeSafe AI's Jev decision model as four conservative, read-only MCP tools for **bounded probabilistic decisions**. @@ -92,15 +92,59 @@ Successful responses include local metadata similar to: That metadata is added by this MCP server; it is not Jev model output. -## 60-second install +## One-command install -Requirements: +No manual MCP JSON/TOML editing is required. The bootstrap installs a shared +runtime at `~/.jev-mcp/runtime`, asks for the TypeSafe key with hidden input, +and configures the selected client. -- Node.js 20+ -- a TypeSafe API key / applicable TypeSafe access and credits -- an MCP host that can launch a local stdio server +macOS / Linux: -Clone and set up: +```bash +# Codex +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex + +# Claude Code +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- claude-code + +# Kimi Code +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- kimi + +# ZCode +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- zcode + +# Cursor +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- cursor + +# Gemini CLI +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- gemini +``` + +Windows PowerShell (replace `codex` with another target as needed): + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1))) -Target codex +``` + +To configure every detected user-level client: + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash +``` + +The key is stored only in `~/.jev-mcp/.env`; client MCP configs contain no +TypeSafe credential. Existing JSON configs are backed up before modification. + +Supported targets: Codex, Claude Code, Kimi Code, ZCode, Cursor, Gemini CLI, +Windsurf-compatible config, generic `.agents/mcp.json`, and project-scoped +VS Code/Copilot Agent configuration. + +See [Installation](docs/INSTALLATION.md) for Windows commands, updates, +uninstall, `--skip-key`, and advanced controls. + +### Source install + +For contributors or users who prefer a local checkout: ```bash git clone https://github.com/Afloat16/jev-mcp.git @@ -116,12 +160,6 @@ cd jev-mcp ./setup.ps1 ``` -The setup script reads the API key without echoing it, stores it only in the -local gitignored `.env` file when needed, installs dependencies, and runs local -checks. - -For a more explicit walkthrough, see [Quick start](docs/QUICKSTART.md). - ## Codex configuration Add this to `~/.codex/config.toml` and replace the path: @@ -166,9 +204,9 @@ structured output, orchestration, explicit thresholds, or shared agent workflows | `JEV_MODEL` | no | `jev-latest` | Jev model override | | `TYPESAFE_BASE_URL` | no | `https://api.typesafe.ai` | API base URL | | `TYPESAFE_TIMEOUT_MS` | no | `15000` | Request timeout, 250–120000 ms | -| `JEV_ENV_FILE` | no | project `.env` | Alternate env file path | +| `JEV_ENV_FILE` | no | auto | Explicit env file; otherwise project `.env`, then `~/.jev-mcp/.env` | -Existing process environment variables override values loaded from `.env`. +Existing process environment variables override file values. Without `JEV_ENV_FILE`, a checkout-local `.env` is loaded before the installer-managed `~/.jev-mcp/.env`. ### Secret-handling rules @@ -227,6 +265,8 @@ should be documented in [CHANGELOG.md](CHANGELOG.md) and migration notes. ## Documentation +- [One-click installation](docs/INSTALLATION.md) +- [Supported AI clients](docs/CLIENTS.md) - [Quick start](docs/QUICKSTART.md) - [Architecture](docs/ARCHITECTURE.md) - [Security and privacy model](docs/SECURITY-MODEL.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 5491cdf..3870b91 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,7 +7,7 @@ **非官方、社区维护的 TypeSafe AI Jev MCP Server。** -[English](README.md) · [快速开始](docs/QUICKSTART.md) · [示例](examples/README.md) · [安全说明](SECURITY.md) · [FAQ](docs/FAQ.md) +[English](README.md) · [一键安装](docs/INSTALLATION.md) · [快速开始](docs/QUICKSTART.md) · [示例](examples/README.md) · [安全说明](SECURITY.md) · [FAQ](docs/FAQ.md) `jev-mcp` 将 TypeSafe AI 的 Jev 决策模型暴露为 4 个保守、只读的 MCP 工具, 用于**边界明确的概率决策**。 @@ -72,16 +72,59 @@ API key 只放在本地进程环境变量或被 Git 忽略的 `.env` 文件中 四个工具均声明为只读,不会修改文件、执行 shell、部署基础设施,也不会切换你选择的模型。 -## 60 秒安装 +## 一条命令安装 -要求: - -- Node.js 20+ -- TypeSafe API key / 可用的 TypeSafe 账户与额度 -- 支持启动本地 stdio MCP server 的客户端 +无需手工修改 MCP JSON/TOML。安装器会把共享运行时安装到 +`~/.jev-mcp/runtime`,隐藏输入 TypeSafe key,并自动配置目标 AI。 macOS / Linux: +```bash +# Codex +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex + +# Claude Code +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- claude-code + +# Kimi Code +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- kimi + +# ZCode +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- zcode + +# Cursor +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- cursor + +# Gemini CLI +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- gemini +``` + +Windows PowerShell(把 `codex` 换成其他目标即可): + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1))) -Target codex +``` + +自动配置检测到的用户级客户端: + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash +``` + +TypeSafe key 只保存在本机 `~/.jev-mcp/.env`,不会写入各 AI 的 MCP 配置; +修改已有 JSON 配置前会自动保存备份。 + +当前支持 Codex、Claude Code、Kimi Code、ZCode、Cursor、Gemini CLI、 +Windsurf 兼容配置、通用 `.agents/mcp.json`,以及项目级 +VS Code/Copilot Agent 配置。 + +完整 Windows 命令、更新、卸载、`--skip-key` 和高级参数见 +[安装说明](docs/INSTALLATION.md)。 + +### 从源码安装 + +适合贡献者或希望固定本地 checkout 的用户: + ```bash git clone https://github.com/Afloat16/jev-mcp.git cd jev-mcp @@ -96,10 +139,6 @@ cd jev-mcp ./setup.ps1 ``` -安装脚本会静默读取 API key,需要时写入本地 gitignored `.env`,安装依赖并运行本地检查。 - -更详细步骤见 [快速开始](docs/QUICKSTART.md)。 - ## Codex 配置 加入 `~/.codex/config.toml`,并替换绝对路径: @@ -142,9 +181,9 @@ MCP 真正有价值的地方是**稳定工具边界**:结构化输出、可重 | `JEV_MODEL` | 否 | `jev-latest` | Jev 模型覆盖 | | `TYPESAFE_BASE_URL` | 否 | `https://api.typesafe.ai` | API 地址 | | `TYPESAFE_TIMEOUT_MS` | 否 | `15000` | 250–120000 ms 请求超时 | -| `JEV_ENV_FILE` | 否 | 项目 `.env` | 其他 env 文件路径 | +| `JEV_ENV_FILE` | 否 | 自动 | 显式 env 文件;否则先项目 `.env`,再 `~/.jev-mcp/.env` | -进程环境变量优先于 `.env` 中的同名值。 +进程环境变量优先于文件中的同名值。未指定 `JEV_ENV_FILE` 时,会先读取项目 `.env`,再读取安装器管理的 `~/.jev-mcp/.env`。 ### 密钥规则 @@ -188,6 +227,8 @@ npm run inspect ## 文档 +- [一键安装](docs/INSTALLATION.md) +- [支持的 AI 客户端](docs/CLIENTS.md) - [快速开始](docs/QUICKSTART.md) - [架构](docs/ARCHITECTURE.md) - [安全与隐私模型](docs/SECURITY-MODEL.md) diff --git a/docs/CLIENTS.md b/docs/CLIENTS.md new file mode 100644 index 0000000..08e31df --- /dev/null +++ b/docs/CLIENTS.md @@ -0,0 +1,61 @@ +# Supported AI clients + +The installer targets common MCP-capable coding agents while keeping the +TypeSafe credential outside every client config. + +| Client | Installer ID | Scope | Integration | +| --- | --- | --- | --- | +| OpenAI Codex CLI + IDE extension | `codex` | user | official `codex mcp` CLI | +| Anthropic Claude Code | `claude-code` | user | official `claude mcp` CLI | +| Kimi Code | `kimi` | user | `~/.kimi-code/mcp.json` | +| ZCode | `zcode` | user | `~/.zcode/cli/config.json` | +| Cursor | `cursor` | user | `~/.cursor/mcp.json` | +| Gemini CLI | `gemini` | user | `~/.gemini/settings.json` | +| Windsurf-compatible config | `windsurf` | user | Windsurf MCP config | +| Generic agents config | `agents` | user | `~/.agents/mcp.json` | +| VS Code / Copilot Agent | `vscode` | project | `.vscode/mcp.json` | + +## Shared runtime model + +All configured clients point to the same managed local runtime: + +```text +~/.jev-mcp/runtime +``` + +and the same local credential store: + +```text +~/.jev-mcp/.env +``` + +This has three advantages: + +- no API key is duplicated into multiple AI configuration files; +- clients do not need network access just to start the MCP server; +- rerunning the bootstrap updates one shared runtime for every configured + client. + +## Design rules + +The installer: + +- never embeds the TypeSafe API key in an AI client's MCP config; +- backs up existing JSON configuration before modification; +- preserves unrelated keys in existing JSON configuration; +- uses an official client CLI when that is the safer documented path; +- uses absolute local Node/runtime paths for stable stdio startup; +- never sends a TypeSafe API request during installation; +- configures Kimi for deferred Jev tool loading. + +## New client requests + +When adding another client, prefer in this order: + +1. an official client CLI for MCP registration; +2. an officially documented user-level configuration file; +3. an industry-standard generic MCP file; +4. a clearly labeled compatibility path when no first-party mechanism exists. + +Every new target should include installer tests, a bootstrap smoke test where +practical, and a check that no credential is written to client config. diff --git a/docs/FAQ.md b/docs/FAQ.md index 511ee17..dc4329d 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -29,9 +29,11 @@ change the host model selected by the user. ## Does the API key get sent to the host model? -The server reads the key from its local environment and uses it as an -Authorization header for the configured API request. The tool output does not -intentionally include the credential. +The server reads the key from its local process environment or a local +credential file. The one-command installer stores the shared key in +`~/.jev-mcp/.env` and does not place it in AI client MCP configuration. The +server uses the key only as the Authorization header for the configured API +request; tool output does not intentionally include the credential. Do not put credentials into tool `state`, prompts, screenshots, logs, issues, or examples. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..6019661 --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,199 @@ +# Installation + +`jev-mcp` provides a bootstrap installer for major MCP-capable AI coding +clients. Users do not need to clone this repository or manually edit JSON/TOML. + +## Prerequisites + +- Node.js 20+ +- Git +- npm (bundled with Node.js) +- a TypeSafe API key / applicable TypeSafe access and credits + +The runtime is installed to: + +```text +~/.jev-mcp/runtime +``` + +The TypeSafe credential is stored separately at: + +```text +~/.jev-mcp/.env +``` + +The key is entered with hidden terminal input and is **not** written into the +AI client's MCP configuration. + +Existing JSON client configs are backed up to a sibling `.jev-mcp.bak` before +they are changed. + +## macOS / Linux + +### Codex + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex +``` + +### Claude Code + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- claude-code +``` + +### Kimi Code + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- kimi +``` + +### ZCode + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- zcode +``` + +### Cursor + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- cursor +``` + +### Gemini CLI + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- gemini +``` + +### All detected user-level clients + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash +``` + +The `all` flow intentionally skips the project-scoped VS Code target. Run the +installer with `vscode` from the project that should receive `.vscode/mcp.json`. + +## Windows PowerShell + +### Codex + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1))) -Target codex +``` + +Replace `codex` with `claude-code`, `kimi`, `zcode`, `cursor`, +`gemini`, `windsurf`, `agents`, or `vscode`. + +Configure all detected user-level clients: + +```powershell +irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1 | iex +``` + +## What the bootstrap does + +1. verifies Git, Node.js 20+, and npm; +2. installs or updates a managed checkout at `~/.jev-mcp/runtime`; +3. performs a locked `npm ci` install and TypeScript build; +4. prompts for the TypeSafe key with hidden input when no local key exists; +5. stores that key only in `~/.jev-mcp/.env`; +6. configures the selected AI client to launch the local runtime directly. + +Client configs therefore use a durable command equivalent to: + +```text + ~/.jev-mcp/runtime/dist/cli.js server +``` + +They do not depend on npm/GitHub every time the AI starts. + +Rerunning the same bootstrap command updates the managed runtime and refreshes +the client configuration. + +## Supported targets + +| Client | Target | Configuration method | +| --- | --- | --- | +| OpenAI Codex CLI + IDE extension | `codex` | `codex mcp add` | +| Anthropic Claude Code | `claude-code` | `claude mcp add --scope user` | +| Kimi Code | `kimi` | `~/.kimi-code/mcp.json` | +| ZCode | `zcode` | `~/.zcode/cli/config.json` | +| Cursor | `cursor` | `~/.cursor/mcp.json` | +| Gemini CLI | `gemini` | `~/.gemini/settings.json` | +| Windsurf-compatible MCP config | `windsurf` | `~/.codeium/windsurf/mcp_config.json` | +| Generic agents config | `agents` | `~/.agents/mcp.json` | +| VS Code / Copilot Agent workspace | `vscode` | `.vscode/mcp.json` | + +Kimi is configured with deferred MCP loading so Jev tools can be loaded on +demand. + +## Environment-managed credentials + +If you intentionally manage `TYPESAFE_API_KEY` outside jev-mcp, skip local +credential creation: + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex --skip-key +``` + +The MCP process must then inherit `TYPESAFE_API_KEY` from its environment. + +## Updating + +Rerun the installer: + +```bash +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex +``` + +The managed runtime is fetched again and checked out to the configured ref. + +## Uninstalling a client integration + +After installation: + +```bash +node ~/.jev-mcp/runtime/dist/cli.js uninstall cursor +``` + +Remove all supported user-level integrations: + +```bash +node ~/.jev-mcp/runtime/dist/cli.js uninstall all +``` + +Removing client configuration does not delete the TypeSafe credential. To +remove the locally stored credential: + +```bash +node ~/.jev-mcp/runtime/dist/cli.js forget-key +``` + +On Windows use the corresponding path under `$HOME\.jev-mcp\runtime`. + +## Security notes + +Downloading and executing a remote install script is convenient but carries +normal supply-chain risk. Users who prefer to inspect everything first should +download the script or clone the repository, review it, and run it locally. + +The installer never makes a live Jev API call. Live calls only happen after an +MCP client invokes a Jev tool. + +Anything later placed in a Jev tool's `state` is sent to the configured +TypeSafe endpoint. See [SECURITY-MODEL.md](SECURITY-MODEL.md). + +## Advanced installer controls + +The bootstrap recognizes: + +| Variable | Purpose | +| --- | --- | +| `JEV_MCP_CONFIG_HOME` | Override `~/.jev-mcp` | +| `JEV_MCP_RUNTIME_DIR` | Override the managed runtime checkout | +| `JEV_MCP_REPO_URL` | Override the Git repository URL | +| `JEV_MCP_GIT_REF` | Override the fetched branch/tag/ref | + +These are primarily useful for testing, forks, and pinned deployments. diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 3e8c413..c9f500c 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,93 +1,79 @@ # Quick start -This guide gets a local `jev-mcp` server running without putting a TypeSafe -credential in your MCP client configuration. +The fastest path is the bootstrap installer. It creates a managed local runtime, +configures the selected MCP client, and keeps the TypeSafe credential outside +client configuration. ## 1. Requirements - Node.js 20+ - Git +- npm - a TypeSafe API key / applicable TypeSafe access and credits -- an MCP host that can launch a local stdio server +- the target AI client -Check Node: +## 2. Install -```bash -node --version -``` - -## 2. Clone - -```bash -git clone https://github.com/Afloat16/jev-mcp.git -cd jev-mcp -``` - -## 3. Configure the local credential - -macOS / Linux: +Codex on macOS/Linux: ```bash -./setup.sh +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex ``` -Windows PowerShell: +Codex on Windows PowerShell: ```powershell -./setup.ps1 +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1))) -Target codex ``` -The setup script reads the key without echoing it and, when needed, stores it -in a local `.env` file that is ignored by Git. +Other target IDs: -Do not paste a real key into README files, `AGENTS.md`, MCP configuration, -issues, screenshots, or shell history. - -You may instead provide `TYPESAFE_API_KEY` through your own process -environment. Existing process variables override values in `.env`. - -## 4. Verify locally +```text +claude-code +kimi +zcode +cursor +gemini +windsurf +agents +vscode +``` -These checks do not call the TypeSafe API: +Configure every detected user-level client on macOS/Linux: ```bash -npm run doctor -npm run check +curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash ``` -Expected result: configuration checks, tests, type checking, and build succeed. - -## 5. Configure Codex +The installer creates: -Add this to `~/.codex/config.toml` and replace the path: - -```toml -[mcp_servers.jev] -command = "node" -args = ["/ABSOLUTE/PATH/TO/jev-mcp/dist/index.js"] +```text +~/.jev-mcp/runtime # managed local runtime +~/.jev-mcp/.env # local credential file ``` -Restart Codex / start a new session. +The API key is entered with hidden input and is not placed in the target +client's MCP configuration. -Other MCP clients can use the same executable through their local stdio-server -configuration. +See [INSTALLATION.md](INSTALLATION.md) for all clients and lifecycle commands. -## 6. Start with a safe test +## 3. Restart the AI client -Use a synthetic decision first. For example, ask the host to obtain a second -opinion between: +MCP tool discovery normally happens at client/session startup. Restart the +client or open a new session after installation. -- retry once; -- roll back; -- escalate for review. +## 4. Safe first test -Do not use production data or credentials as test input. +Use synthetic state and ask the host model for a second opinion on a bounded +decision such as: -Examples are available in [../examples/README.md](../examples/README.md). +```text +retry / rollback / escalate +``` -## 7. Understand the trust model +Do not use production secrets or customer data as test input. -Jev is advisory. The intended priority order is: +## 5. Trust model ```text deterministic evidence @@ -97,6 +83,16 @@ host-model repository-aware reasoning Jev probabilistic advice ``` -Anything placed in `state` is sent to the configured TypeSafe API endpoint. -Read [SECURITY-MODEL.md](SECURITY-MODEL.md) before using the tool with sensitive -projects. +Anything placed in Jev `state` is sent to the configured TypeSafe API +endpoint. Read [SECURITY-MODEL.md](SECURITY-MODEL.md) before using the tool with +sensitive projects. + +## Source checkout alternative + +```bash +git clone https://github.com/Afloat16/jev-mcp.git +cd jev-mcp +./setup.sh +``` + +On Windows PowerShell use `./setup.ps1`. diff --git a/docs/SECURITY-MODEL.md b/docs/SECURITY-MODEL.md index 415e06d..bfb0200 100644 --- a/docs/SECURITY-MODEL.md +++ b/docs/SECURITY-MODEL.md @@ -36,12 +36,15 @@ alternate endpoint. Review environment configuration before use. ### Credential exposure -The API key is read locally from `.env` or the process environment and sent as -a Bearer token to the configured endpoint. - -Mitigation: `.env` is ignored by Git; setup scripts do not print the key; the -repository includes a tracked-file secret scanner. Rotate any key that has -appeared in a shared surface or Git history. +The API key is read locally from the process environment, an explicitly +configured `JEV_ENV_FILE`, a checkout-local `.env`, or the installer-managed +`~/.jev-mcp/.env`, then sent as a Bearer token to the configured endpoint. + +Mitigation: credential files are kept outside client MCP configuration; +checkout `.env` is ignored by Git; the bootstrap stores its shared credential +under the user's `~/.jev-mcp` directory; setup/install flows do not print the +key; and the repository includes a tracked-file secret scanner. Rotate any key +that has appeared in a shared surface or Git history. ### Dependency / supply-chain risk diff --git a/package.json b/package.json index d552263..8a7444d 100644 --- a/package.json +++ b/package.json @@ -22,19 +22,25 @@ "typesafe-ai", "jev", "codex", - "decision-model" + "decision-model", + "claude-code", + "kimi", + "zcode", + "cursor", + "gemini" ], "scripts": { "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --import tsx --test test/core.test.ts", + "test": "node --import tsx --test test/core.test.ts test/installer.test.ts", "inspect": "npx @modelcontextprotocol/inspector npx tsx src/index.ts", "doctor": "node scripts/doctor.mjs", "docs:check": "node scripts/check-docs.mjs", "secrets:check": "node scripts/check-secrets.mjs", - "check": "npm run secrets:check && npm run docs:check && npm run test && npm run typecheck && npm run build" + "check": "npm run secrets:check && npm run docs:check && npm run test && npm run typecheck && npm run build && npm run cli:smoke", + "cli:smoke": "node dist/cli.js targets" }, "dependencies": { "@modelcontextprotocol/server": "^2.0.0", @@ -44,5 +50,8 @@ "@types/node": "^24.10.1", "tsx": "^4.21.0", "typescript": "^5.8.3" + }, + "bin": { + "jev-mcp": "dist/cli.js" } } diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 4d3f065..e15595c 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -22,7 +22,7 @@ for (const file of markdownFiles) { if ( !target || target.startsWith("#") || - /^(?:https?:|mailto:)/i.test(target) + /^(?:https?:|mailto:|cursor:|vscode:)/i.test(target) ) { continue; } diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs index 9833a73..adc3134 100644 --- a/scripts/doctor.mjs +++ b/scripts/doctor.mjs @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -20,22 +21,40 @@ function fail(message) { if (major >= 20) ok(`Node ${process.version}`); else fail(`Node 20+ required; found ${process.version}`); -const envPath = process.env.JEV_ENV_FILE || resolve(root, ".env"); -if (existsSync(envPath)) { +const candidates = process.env.JEV_ENV_FILE + ? [process.env.JEV_ENV_FILE] + : [ + resolve(root, ".env"), + resolve( + process.env.JEV_MCP_CONFIG_HOME || resolve(homedir(), ".jev-mcp"), + ".env", + ), + ]; + +const envPath = candidates.find((path) => path && existsSync(path)); +if (envPath) { ok(`Environment file found: ${envPath}`); const envText = readFileSync(envPath, "utf8"); - const keyLine = envText.split(/\r?\n/).find((line) => line.trim().startsWith("TYPESAFE_API_KEY=")); - if (!keyLine) fail("TYPESAFE_API_KEY is missing from the local env file"); - else if (/=\s*(?:replace_me)?\s*$/.test(keyLine)) fail("TYPESAFE_API_KEY is still a placeholder"); + const keyLine = envText + .split(/\r?\n/) + .find((line) => line.trim().startsWith("TYPESAFE_API_KEY=")); + if (!keyLine) fail("TYPESAFE_API_KEY is missing from the environment file"); + else if (/=\s*(?:replace_me)?\s*$/.test(keyLine)) + fail("TYPESAFE_API_KEY is still a placeholder"); else ok("TYPESAFE_API_KEY is configured (value not printed)"); } else if (process.env.TYPESAFE_API_KEY) { ok("TYPESAFE_API_KEY is available from the process environment (value not printed)"); } else { - warn("No .env file or process TYPESAFE_API_KEY found. The server can build, but live Jev calls will fail."); + warn( + "No project/user env file or process TYPESAFE_API_KEY found. Live Jev calls will fail until configured.", + ); } if (existsSync(resolve(root, "dist/index.js"))) ok("Built server found at dist/index.js"); else warn("dist/index.js not found; run `npm run build`"); +if (existsSync(resolve(root, "dist/cli.js"))) ok("Installer CLI found at dist/cli.js"); +else warn("dist/cli.js not found; run `npm run build`"); + console.log("\nNo network request was made. Run MCP Inspector for an explicit live test."); process.exit(failed ? 1 : 0); diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..9fe5009 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,85 @@ +param( + [string]$Target = "all", + [switch]$SkipKey +) + +$ErrorActionPreference = "Stop" + +foreach ($command in @("git", "node", "npm")) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "jev-mcp installer: $command is required." + } +} + +$nodeMajor = [int](& node -p 'process.versions.node.split(".")[0]') +if ($nodeMajor -lt 20) { + throw "jev-mcp installer: Node.js 20+ is required; found $(& node -v)." +} + +$configHome = if ([string]::IsNullOrWhiteSpace($env:JEV_MCP_CONFIG_HOME)) { + Join-Path $HOME ".jev-mcp" +} else { + $env:JEV_MCP_CONFIG_HOME +} + +$runtime = if ([string]::IsNullOrWhiteSpace($env:JEV_MCP_RUNTIME_DIR)) { + Join-Path $configHome "runtime" +} else { + $env:JEV_MCP_RUNTIME_DIR +} + +$repoUrl = if ([string]::IsNullOrWhiteSpace($env:JEV_MCP_REPO_URL)) { + "https://github.com/Afloat16/jev-mcp.git" +} else { + $env:JEV_MCP_REPO_URL +} + +$gitRef = if ([string]::IsNullOrWhiteSpace($env:JEV_MCP_GIT_REF)) { + "main" +} else { + $env:JEV_MCP_GIT_REF +} + +New-Item -ItemType Directory -Force -Path $configHome | Out-Null + +if ((Test-Path $runtime) -and -not (Test-Path (Join-Path $runtime ".git"))) { + throw "jev-mcp installer: $runtime exists but is not a jev-mcp Git checkout." +} + +if (-not (Test-Path (Join-Path $runtime ".git"))) { + Write-Host "Installing jev-mcp runtime into $runtime" + & git clone --filter=blob:none --no-checkout $repoUrl $runtime + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} else { + Write-Host "Updating existing jev-mcp runtime in $runtime" +} + +& git -C $runtime fetch --prune origin $gitRef +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& git -C $runtime checkout --detach FETCH_HEAD +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Push-Location $runtime +try { + & npm ci --no-audit --no-fund + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & npm run build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + Pop-Location +} + +$previousRuntime = $env:JEV_MCP_RUNTIME_DIR +$env:JEV_MCP_RUNTIME_DIR = $runtime +try { + $args = @((Join-Path $runtime "dist/cli.js"), "install", $Target) + if ($SkipKey) { $args += "--skip-key" } + & node @args + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + $env:JEV_MCP_RUNTIME_DIR = $previousRuntime +} + +Write-Host "" +Write-Host "jev-mcp runtime: $runtime" +Write-Host "Restart the configured AI client or start a new session before using Jev." diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..35a6e2b --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="${1:-all}" +if [ "$#" -gt 0 ]; then + shift +fi + +for command in git node npm; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "jev-mcp installer: $command is required." >&2 + exit 1 + fi +done + +node_major="$(node -p 'process.versions.node.split(".")[0]')" +if [ "$node_major" -lt 20 ]; then + echo "jev-mcp installer: Node.js 20+ is required; found $(node -v)." >&2 + exit 1 +fi + +config_home="${JEV_MCP_CONFIG_HOME:-$HOME/.jev-mcp}" +runtime="${JEV_MCP_RUNTIME_DIR:-$config_home/runtime}" +repo_url="${JEV_MCP_REPO_URL:-https://github.com/Afloat16/jev-mcp.git}" +git_ref="${JEV_MCP_GIT_REF:-main}" + +mkdir -p "$config_home" +chmod 700 "$config_home" 2>/dev/null || true + +if [ -e "$runtime" ] && [ ! -d "$runtime/.git" ]; then + echo "jev-mcp installer: $runtime exists but is not a jev-mcp Git checkout." >&2 + echo "Set JEV_MCP_RUNTIME_DIR to another directory or remove that path." >&2 + exit 1 +fi + +if [ ! -d "$runtime/.git" ]; then + echo "Installing jev-mcp runtime into $runtime" + git clone --filter=blob:none --no-checkout "$repo_url" "$runtime" +else + echo "Updating existing jev-mcp runtime in $runtime" +fi + +git -C "$runtime" fetch --prune origin "$git_ref" +git -C "$runtime" checkout --detach FETCH_HEAD + +( + cd "$runtime" + npm ci --no-audit --no-fund + npm run build +) + +export JEV_MCP_RUNTIME_DIR="$runtime" + +skip_key=false +for arg in "$@"; do + if [ "$arg" = "--skip-key" ]; then + skip_key=true + break + fi +done + +if [ "$skip_key" = false ] && [ -z "${TYPESAFE_API_KEY:-}" ] && [ ! -t 0 ]; then + if [ -e /dev/tty ]; then + node "$runtime/dist/cli.js" install "$target" "$@" < /dev/tty + else + echo "jev-mcp installer: interactive key entry needs a TTY." >&2 + echo "Set TYPESAFE_API_KEY in the environment or rerun from an interactive terminal." >&2 + exit 1 + fi +else + node "$runtime/dist/cli.js" install "$target" "$@" +fi + +echo +echo "jev-mcp runtime: $runtime" +echo "Restart the configured AI client or start a new session before using Jev." diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..d5010b6 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +import { + INSTALL_TARGETS, + ensureCredential, + installTargets, + removeStoredCredential, + runtimeRoot, + uninstallTargets, + type InstallTarget, +} from "./installer.js"; + +function printHelp(): void { + console.log(` +jev-mcp + +Usage: + jev-mcp server + jev-mcp setup [--reset] + jev-mcp install [--skip-key] + jev-mcp uninstall + jev-mcp targets + jev-mcp runtime + jev-mcp forget-key + +Targets: + codex OpenAI Codex CLI + VS Code extension shared MCP config + claude-code Anthropic Claude Code (user scope) + kimi Kimi Code + zcode ZCode + cursor Cursor + gemini Gemini CLI + windsurf Windsurf-compatible MCP config + agents Generic ~/.agents/mcp.json + vscode VS Code workspace .vscode/mcp.json + +Recommended bootstrap: + macOS/Linux: + curl -fsSL https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.sh | bash -s -- codex + + Windows PowerShell: + & ([scriptblock]::Create((irm https://raw.githubusercontent.com/Afloat16/jev-mcp/main/scripts/install.ps1))) -Target codex + +After bootstrap, the local CLI lives under ~/.jev-mcp/runtime. +`.trim()); +} + +function parseTarget(raw: string): InstallTarget { + if (!INSTALL_TARGETS.includes(raw as InstallTarget)) { + throw new Error(`Unknown target: ${raw}. Run 'jev-mcp targets'.`); + } + return raw as InstallTarget; +} + +async function main(): Promise { + const [command = "server", ...rest] = process.argv.slice(2); + + if (command === "server") { + await import("./index.js"); + return; + } + + if (command === "help" || command === "--help" || command === "-h") { + printHelp(); + return; + } + + if (command === "targets") { + console.log(INSTALL_TARGETS.join("\n")); + return; + } + + if (command === "runtime") { + console.log(runtimeRoot()); + return; + } + + if (command === "setup") { + const path = await ensureCredential({ reset: rest.includes("--reset") }); + console.log(`Credential configured locally at ${path}. The key value was not printed.`); + return; + } + + if (command === "forget-key") { + removeStoredCredential(); + console.log("Removed the locally stored jev-mcp credential file."); + return; + } + + if (command === "install") { + const rawTarget = rest.find((arg) => !arg.startsWith("-")); + if (!rawTarget) throw new Error("Missing target. Use 'jev-mcp install '."); + const skipKey = rest.includes("--skip-key"); + + const results = + rawTarget === "all" + ? await installTargets(INSTALL_TARGETS.filter((target) => target !== "vscode"), { + skipKey, + detectOnly: true, + }) + : await installTargets([parseTarget(rawTarget)], { skipKey }); + + for (const result of results) { + const symbol = result.status === "installed" ? "✓" : "·"; + console.log(`${symbol} ${result.target}: ${result.status} — ${result.detail}`); + } + + console.log("\nRestart the configured AI client or start a new session before using Jev."); + return; + } + + if (command === "uninstall") { + const rawTarget = rest.find((arg) => !arg.startsWith("-")); + if (!rawTarget) throw new Error("Missing target. Use 'jev-mcp uninstall '."); + const targets = + rawTarget === "all" + ? INSTALL_TARGETS.filter((target) => target !== "vscode") + : [parseTarget(rawTarget)]; + + for (const result of uninstallTargets(targets)) { + console.log(`✓ ${result.target}: ${result.detail}`); + } + console.log( + "Stored TypeSafe credentials were left untouched. Run 'jev-mcp forget-key' to remove them.", + ); + return; + } + + throw new Error(`Unknown command: ${command}`); +} + +main().catch((error) => { + console.error(`jev-mcp: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/src/index.ts b/src/index.ts index ed6dc0b..38371ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; import { McpServer } from "@modelcontextprotocol/server"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; @@ -19,20 +20,32 @@ import { type JsonValue, } from "./core.js"; -function loadProjectEnv(): void { - const moduleDir = dirname(fileURLToPath(import.meta.url)); - const projectRoot = resolve(moduleDir, ".."); - const envPath = process.env.JEV_ENV_FILE ?? resolve(projectRoot, ".env"); - +function loadEnvFile(envPath: string): void { if (!existsSync(envPath)) return; - const parsed = parseEnvFile(readFileSync(envPath, "utf8")); for (const [key, value] of Object.entries(parsed)) { if (process.env[key] === undefined) process.env[key] = value; } } -loadProjectEnv(); +function loadEnvironment(): void { + if (process.env.JEV_ENV_FILE?.trim()) { + loadEnvFile(process.env.JEV_ENV_FILE.trim()); + return; + } + + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const projectRoot = resolve(moduleDir, ".."); + const userConfigHome = + process.env.JEV_MCP_CONFIG_HOME?.trim() || resolve(homedir(), ".jev-mcp"); + + // Explicit process environment wins. A checkout-local .env has priority over + // the global installer-managed credential file. + loadEnvFile(resolve(projectRoot, ".env")); + loadEnvFile(resolve(userConfigHome, ".env")); +} + +loadEnvironment(); const stateSchema = z.union([ z.string(), diff --git a/src/installer.ts b/src/installer.ts new file mode 100644 index 0000000..3be60c4 --- /dev/null +++ b/src/installer.ts @@ -0,0 +1,441 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parseEnvFile } from "./core.js"; + +export const SERVER_NAME = "jev"; + +export type InstallTarget = + | "codex" + | "claude-code" + | "kimi" + | "zcode" + | "cursor" + | "gemini" + | "windsurf" + | "agents" + | "vscode"; + +export const INSTALL_TARGETS: InstallTarget[] = [ + "codex", + "claude-code", + "kimi", + "zcode", + "cursor", + "gemini", + "windsurf", + "agents", + "vscode", +]; + +type JsonObject = Record; + +export function configHome(): string { + return process.env.JEV_MCP_CONFIG_HOME?.trim() || join(homedir(), ".jev-mcp"); +} + +export function userEnvPath(): string { + return join(configHome(), ".env"); +} + +export function runtimeRoot(): string { + const explicit = process.env.JEV_MCP_RUNTIME_DIR?.trim(); + if (explicit) return resolve(explicit); + + const moduleDir = dirname(fileURLToPath(import.meta.url)); + return resolve(moduleDir, ".."); +} + +export function serverLauncher( + runtime = runtimeRoot(), + nodeExecutable = process.execPath, +): { + command: string; + args: string[]; +} { + return { + command: nodeExecutable, + args: [resolve(runtime, "dist", "cli.js"), "server"], + }; +} + +export function genericMcpEntry( + runtime = runtimeRoot(), + nodeExecutable = process.execPath, +): JsonObject { + return serverLauncher(runtime, nodeExecutable); +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readJson(path: string): JsonObject { + if (!existsSync(path)) return {}; + const text = readFileSync(path, "utf8").trim(); + if (!text) return {}; + const parsed: unknown = JSON.parse(text); + if (!isObject(parsed)) { + throw new Error(`Expected a JSON object in ${path}`); + } + return parsed; +} + +function atomicWriteJson(path: string, value: JsonObject): void { + mkdirSync(dirname(path), { recursive: true }); + const temp = `${path}.jev-mcp.tmp-${process.pid}`; + const backup = `${path}.jev-mcp.bak`; + + if (existsSync(path)) copyFileSync(path, backup); + writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + renameSync(temp, path); +} + +function setNested(root: JsonObject, path: string[], value: unknown): void { + let current = root; + for (const key of path.slice(0, -1)) { + if (!isObject(current[key])) current[key] = {}; + current = current[key] as JsonObject; + } + current[path[path.length - 1]] = value; +} + +function deleteNested(root: JsonObject, path: string[]): boolean { + let current = root; + for (const key of path.slice(0, -1)) { + if (!isObject(current[key])) return false; + current = current[key] as JsonObject; + } + return delete current[path[path.length - 1]]; +} + +export function jsonConfigForTarget( + target: Exclude, + runtime = runtimeRoot(), + nodeExecutable = process.execPath, +): { path: string; keyPath: string[]; value: JsonObject } { + const home = homedir(); + const base = serverLauncher(runtime, nodeExecutable); + + switch (target) { + case "kimi": + return { + path: join(home, ".kimi-code", "mcp.json"), + keyPath: ["mcpServers", SERVER_NAME], + value: { + ...base, + deferred: true, + startupTimeoutMs: 120000, + toolTimeoutMs: 120000, + }, + }; + case "zcode": + return { + path: join(home, ".zcode", "cli", "config.json"), + keyPath: ["mcp", "servers", SERVER_NAME], + value: { ...base, enable: true }, + }; + case "cursor": + return { + path: join(home, ".cursor", "mcp.json"), + keyPath: ["mcpServers", SERVER_NAME], + value: { type: "stdio", ...base }, + }; + case "gemini": + return { + path: join(home, ".gemini", "settings.json"), + keyPath: ["mcpServers", SERVER_NAME], + value: { ...base, timeout: 120000 }, + }; + case "windsurf": + return { + path: join(home, ".codeium", "windsurf", "mcp_config.json"), + keyPath: ["mcpServers", SERVER_NAME], + value: base, + }; + case "agents": + return { + path: join(home, ".agents", "mcp.json"), + keyPath: ["mcpServers", SERVER_NAME], + value: base, + }; + case "vscode": + return { + path: resolve(process.cwd(), ".vscode", "mcp.json"), + keyPath: ["servers", SERVER_NAME], + value: { type: "stdio", ...base }, + }; + } +} + +function commandExists(command: string): boolean { + const probe = + process.platform === "win32" + ? spawnSync("where", [command], { stdio: "ignore" }) + : spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" }); + return probe.status === 0; +} + +function runCli( + command: string, + args: string[], + options: { ignoreFailure?: boolean } = {}, +): boolean { + const result = + process.platform === "win32" + ? spawnSync("cmd", ["/d", "/s", "/c", command, ...args], { + stdio: options.ignoreFailure ? "ignore" : "inherit", + }) + : spawnSync(command, args, { + stdio: options.ignoreFailure ? "ignore" : "inherit", + }); + + if (result.error && !options.ignoreFailure) throw result.error; + if (result.status !== 0 && !options.ignoreFailure) { + throw new Error(`${command} exited with status ${result.status ?? "unknown"}`); + } + return result.status === 0; +} + +async function readSecret(prompt: string): Promise { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { + throw new Error( + "Interactive secret input requires a TTY. Set TYPESAFE_API_KEY in the environment and rerun.", + ); + } + + process.stdout.write(prompt); + process.stdin.setEncoding("utf8"); + process.stdin.setRawMode(true); + process.stdin.resume(); + + return await new Promise((resolvePromise, reject) => { + let value = ""; + + const cleanup = () => { + process.stdin.off("data", onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + }; + + const onData = (chunk: string | Buffer) => { + const text = String(chunk); + for (const char of text) { + if (char === "\u0003") { + cleanup(); + process.stdout.write("\n"); + reject(new Error("Cancelled.")); + return; + } + + if (char === "\r" || char === "\n") { + cleanup(); + process.stdout.write("\n"); + resolvePromise(value); + return; + } + + if (char === "\u007f" || char === "\b") { + value = value.slice(0, -1); + continue; + } + + if (char >= " ") value += char; + } + }; + + process.stdin.on("data", onData); + }); +} + +function validateKey(key: string): string { + const value = key.trim(); + if (!value) throw new Error("TypeSafe API key cannot be empty."); + if (/\s/.test(value)) throw new Error("TypeSafe API key must not contain whitespace."); + return value; +} + +export async function ensureCredential(options: { + reset?: boolean; + skip?: boolean; +} = {}): Promise { + if (options.skip) return null; + + const path = userEnvPath(); + if (!options.reset && existsSync(path)) { + const parsed = parseEnvFile(readFileSync(path, "utf8")); + if (parsed.TYPESAFE_API_KEY?.trim()) return path; + } + + const key = validateKey( + process.env.TYPESAFE_API_KEY ?? + (await readSecret("TypeSafe API key (input hidden): ")), + ); + + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temp = `${path}.tmp-${process.pid}`; + writeFileSync( + temp, + [ + "# Local jev-mcp credential file. Never commit or share this file.", + `TYPESAFE_API_KEY=${key}`, + "JEV_MODEL=jev-latest", + "TYPESAFE_BASE_URL=https://api.typesafe.ai", + "TYPESAFE_TIMEOUT_MS=15000", + "", + ].join("\n"), + { encoding: "utf8", mode: 0o600 }, + ); + renameSync(temp, path); + try { + chmodSync(path, 0o600); + } catch { + // Best effort on platforms/filesystems without POSIX permissions. + } + + return path; +} + +function installJsonTarget( + target: Exclude, +): string { + const config = jsonConfigForTarget(target); + const root = readJson(config.path); + setNested(root, config.keyPath, config.value); + atomicWriteJson(config.path, root); + return config.path; +} + +function uninstallJsonTarget( + target: Exclude, +): string { + const config = jsonConfigForTarget(target); + if (!existsSync(config.path)) return config.path; + const root = readJson(config.path); + if (deleteNested(root, config.keyPath)) atomicWriteJson(config.path, root); + return config.path; +} + +function installCliTarget(target: "codex" | "claude-code"): void { + const launcher = serverLauncher(); + + if (target === "codex") { + if (!commandExists("codex")) { + throw new Error("Codex CLI was not found on PATH."); + } + runCli("codex", ["mcp", "remove", SERVER_NAME], { ignoreFailure: true }); + runCli("codex", [ + "mcp", + "add", + SERVER_NAME, + "--", + launcher.command, + ...launcher.args, + ]); + return; + } + + if (!commandExists("claude")) { + throw new Error("Claude Code CLI was not found on PATH."); + } + runCli("claude", ["mcp", "remove", SERVER_NAME], { ignoreFailure: true }); + runCli("claude", [ + "mcp", + "add", + "--scope", + "user", + SERVER_NAME, + "--", + launcher.command, + ...launcher.args, + ]); +} + +function uninstallCliTarget(target: "codex" | "claude-code"): void { + const command = target === "codex" ? "codex" : "claude"; + if (!commandExists(command)) return; + runCli(command, ["mcp", "remove", SERVER_NAME], { ignoreFailure: true }); +} + +function targetLooksInstalled(target: InstallTarget): boolean { + if (target === "codex") return commandExists("codex"); + if (target === "claude-code") return commandExists("claude"); + + const config = jsonConfigForTarget(target); + return existsSync(dirname(config.path)); +} + +export async function installTargets( + targets: InstallTarget[], + options: { skipKey?: boolean; detectOnly?: boolean } = {}, +): Promise> { + await ensureCredential({ skip: options.skipKey }); + + const results: Array<{ + target: InstallTarget; + status: "installed" | "skipped"; + detail: string; + }> = []; + + for (const target of targets) { + if (options.detectOnly && !targetLooksInstalled(target) && target !== "agents") { + results.push({ + target, + status: "skipped", + detail: "client/config directory not detected", + }); + continue; + } + + try { + if (target === "codex" || target === "claude-code") { + installCliTarget(target); + results.push({ target, status: "installed", detail: "configured via client CLI" }); + } else { + const path = installJsonTarget(target); + results.push({ target, status: "installed", detail: path }); + } + } catch (error) { + if (options.detectOnly) { + results.push({ + target, + status: "skipped", + detail: error instanceof Error ? error.message : String(error), + }); + continue; + } + throw error; + } + } + + return results; +} + +export function uninstallTargets( + targets: InstallTarget[], +): Array<{ target: InstallTarget; detail: string }> { + return targets.map((target) => { + if (target === "codex" || target === "claude-code") { + uninstallCliTarget(target); + return { target, detail: "removed via client CLI when present" }; + } + return { target, detail: uninstallJsonTarget(target) }; + }); +} + +export function removeStoredCredential(): void { + const path = userEnvPath(); + if (existsSync(path)) rmSync(path); +} diff --git a/test/installer.test.ts b/test/installer.test.ts new file mode 100644 index 0000000..09f9273 --- /dev/null +++ b/test/installer.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import test from "node:test"; + +import { + SERVER_NAME, + genericMcpEntry, + jsonConfigForTarget, + serverLauncher, +} from "../src/installer.js"; + +const runtime = resolve("/tmp", "jev-mcp-runtime"); +const nodeExecutable = resolve("/opt", "node", "bin", "node"); + +test("launcher uses a durable local runtime and explicit Node executable", () => { + const launcher = serverLauncher(runtime, nodeExecutable); + assert.equal(launcher.command, nodeExecutable); + assert.deepEqual(launcher.args, [resolve(runtime, "dist", "cli.js"), "server"]); +}); + +test("generic MCP entry never contains a TypeSafe API key", () => { + const entry = JSON.stringify(genericMcpEntry(runtime, nodeExecutable)); + assert.match(entry, /dist/); + assert.match(entry, /cli\.js/); + assert.doesNotMatch(entry, /TYPESAFE_API_KEY/); + assert.doesNotMatch(entry, /apikey_/); +}); + +test("Kimi configuration uses deferred loading", () => { + const config = jsonConfigForTarget("kimi", runtime, nodeExecutable); + assert.deepEqual(config.keyPath, ["mcpServers", SERVER_NAME]); + assert.equal(config.value.deferred, true); +}); + +test("ZCode configuration uses its native user-level MCP nesting", () => { + const config = jsonConfigForTarget("zcode", runtime, nodeExecutable); + assert.deepEqual(config.keyPath, ["mcp", "servers", SERVER_NAME]); +}); + +test("Cursor config uses stdio and the durable runtime", () => { + const config = jsonConfigForTarget("cursor", runtime, nodeExecutable); + assert.deepEqual(config.keyPath, ["mcpServers", SERVER_NAME]); + assert.equal(config.value.command, nodeExecutable); + assert.deepEqual(config.value.args, [resolve(runtime, "dist", "cli.js"), "server"]); + assert.doesNotMatch(JSON.stringify(config.value), /TYPESAFE_API_KEY/); +}); + +test("VS Code configuration is workspace-scoped", () => { + const config = jsonConfigForTarget("vscode", runtime, nodeExecutable); + assert.deepEqual(config.keyPath, ["servers", SERVER_NAME]); + assert.match(config.path, /\.vscode[\\/]mcp\.json$/); +});