diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bd004..0ad515a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 3. Merge to main — publish.yml ships to PyPI on the pyproject.toml change and tags the release. --> +## [Unreleased] + +### Added + +- **Per-client MCP installers**: `hebb gemini install`, `hebb goose install`, + `hebb opencode install` (with `--scope project|user`), and `hebb amp install` + (with `--scope project|user`) register the Hebb Mind MCP server in each + client's config file. Each has a matching `uninstall` command. All installers + resolve the absolute `hebb-mcp` path via `hebb_mcp_command()`, are idempotent, + preserve unrelated config entries, and use atomic writes. +- **S1 command-drift CI guard** (`tests/unit/test_command_drift.py`): asserts + every `hebb` command referenced in MCP integration docs, generated hook + configs, and the `hebb-mcp` entrypoint resolves to a registered Click command + or importable module. + +### Documentation + +- Expanded MCP integration guide (`repo_pages/guide/mcp-integration.md` + zh + mirror) from 4 clients to a 19-client quick-connect matrix covering Claude + Code, Codex, Amp, Cline, Claude Desktop, Copilot, Cursor, Gemini CLI, Goose, + Kiro, LM Studio, opencode, VS Code, Warp, Windsurf, and UI-only clients + (Antigravity, Factory, Junie, Qodo Gen). Each entry includes the exact config + file path, format, and absolute-path guidance. + ## [0.3.0] - 2026-06-29 ### Added diff --git a/pyproject.toml b/pyproject.toml index 1d623b4..eba2b41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "httpx>=0.27.0", "PyJWT>=2.8.0", "networkx>=3.1", - "mcp>=1.0.0", + "mcp>=1.0.0,<2.0.0", # Repairs malformed LLM JSON (doubled braces, truncation, trailing commas). # Pure-Python, MIT, no mandatory transitive deps. "json-repair>=0.30.0", diff --git a/repo_pages/guide/mcp-integration.md b/repo_pages/guide/mcp-integration.md index 6a32955..445f8d5 100644 --- a/repo_pages/guide/mcp-integration.md +++ b/repo_pages/guide/mcp-integration.md @@ -1,10 +1,10 @@ --- -description: "Connect Claude Code, Codex, Cursor, and Claude Desktop to long-term AI agent memory over MCP — write, recall via hybrid vector+keyword search, and consolidate." +description: "Connect Claude Code, Codex, Cursor, Windsurf, VS Code, and 15+ MCP-compatible clients to long-term AI agent memory — write, recall via hybrid vector+keyword search, and consolidate." --- # MCP Integration -Hebb Mind provides an MCP (Model Context Protocol) server that exposes memory operations as tools. Claude Code, Codex, Cursor, and other MCP-compatible clients can use it directly. +Hebb Mind provides an MCP (Model Context Protocol) server that exposes memory operations as tools. Any MCP-compatible client can use it directly. Below is the full quick-connect matrix — find your client and paste the snippet. ## Prerequisites @@ -24,27 +24,76 @@ hebb service install # registers the background service (no admin by default) | `consolidate` | Trigger memory consolidation | none | | `ingest_conversation` | Ingest a conversation export (Claude Code JSONL / ChatGPT JSON / plain text) — auto-detects format, normalizes turns, stores each turn | `content`, `format_hint?`, `importance?` | -## Configuration - -The MCP server automatically discovers the service address from `hebb.json`. For the common case (service running locally), no configuration is needed — just add the command. +## Quick-Connect Matrix ::: tip Use the absolute path to `hebb-mcp` -The snippets below show `command: "hebb-mcp"` for brevity, but a **bare** `hebb-mcp` can fail to launch under GUI-launched apps (Claude Desktop, Cursor) that don't inherit your shell `PATH` — the MCP server then silently never starts. Run `which hebb-mcp` (Windows: `where hebb-mcp`) and use the **absolute path** as the `command`. The `hebb claude-code install` / `hebb codex install` commands already do this for you. +All snippets below use `/absolute/path/to/hebb-mcp` as a placeholder. A **bare** `hebb-mcp` can fail to launch under GUI-launched apps (Claude Desktop, Cursor, Windsurf, LM Studio, etc.) that don't inherit your shell `PATH`. Run `which hebb-mcp` (Windows: `where hebb-mcp`) and replace the placeholder with the real path. The `hebb claude-code install` / `hebb codex install` commands already resolve this for you. ::: -If the service runs on a remote host or non-default address, set `HEBB_URL`: +If the Hebb Mind service runs on a remote host or non-default address, add an `env` block with `HEBB_URL`: + +```json +"env": { "HEBB_URL": "http://192.168.1.100:8321" } +``` + +### Clients with first-class installers + +#### Claude Code + +```bash +hebb claude-code install --scope user # hooks + MCP, absolute path auto-resolved +``` + +MCP-only (project-level `.mcp.json` or global `~/.claude.json`): + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### Codex -### Claude Code +```bash +hebb codex install # project MCP + lifecycle hooks (default) +codex mcp list +``` -Recommended: +Native Codex command: ```bash -hebb claude-code install --scope user +codex mcp add hebb -- "$(which hebb-mcp)" +``` + +### Clients with config snippets + +#### Amp + +Amp CLI stores MCP servers in `.amp/settings.json` (project-scoped) or `~/.config/amp/settings.json` (global). Use the `amp mcp add` command: + +```bash +amp mcp add hebb -- /absolute/path/to/hebb-mcp +``` + +Or edit `.amp/settings.json` directly: + +```json +{ + "amp.mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} ``` -MCP-only: +#### Claude Desktop -Add to your project's `.mcp.json` (replace `/absolute/path/to/hebb-mcp` with the output of `which hebb-mcp`): +Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`; Windows: `%APPDATA%\Claude\claude_desktop_config.json`). Claude Desktop is a GUI app — the absolute path is required: ```json { @@ -56,41 +105,163 @@ Add to your project's `.mcp.json` (replace `/absolute/path/to/hebb-mcp` with the } ``` -Or add globally in `~/.claude.json`. +#### Cline -If the service runs on a non-default address, set the URL explicitly: +Cline stores MCP config at two locations depending on how you use it: `~/.cline/mcp.json` for the Cline CLI, or the VS Code extension's MCP settings file (open via Cline panel → MCP Servers icon → Configure tab → Configure MCP Servers). Add: ```json { "mcpServers": { "hebb": { "command": "/absolute/path/to/hebb-mcp", - "env": { - "HEBB_URL": "http://192.168.1.100:8321" - } + "disabled": false, + "autoApprove": [] } } } ``` -### Codex +#### Copilot (VS Code) -Recommended: +Copilot in VS Code uses the same `.vscode/mcp.json` as VS Code native MCP (see below). Alternatively, use the command palette: -```bash -hebb codex install # project MCP + lifecycle hooks (default) -codex mcp list +```text +MCP: Add Server → stdio → name: hebb → command: /absolute/path/to/hebb-mcp +``` + +#### Cursor + +Edit `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project-scoped). Open via **Settings → Tools & MCP → New MCP Server**, or create the file: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} ``` -Native Codex command (pass the absolute path so it resolves regardless of how Codex is launched): +#### Gemini CLI + +Edit `~/.gemini/settings.json` and add a `mcpServers` entry: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### Goose + +Edit `~/.config/goose/config.yaml` (macOS/Linux) or `%APPDATA%\Block\goose\config\config.yaml` (Windows). Add under `extensions`: + +```yaml +extensions: + hebb: + type: stdio + name: hebb + enabled: true + cmd: /absolute/path/to/hebb-mcp + args: [] + envs: {} + timeout: 300 +``` + +Or use the CLI wizard: `goose configure` → Add Extension → Stdio Extension → name: `hebb`, command: `/absolute/path/to/hebb-mcp`. + +#### Kiro + +Edit `.kiro/settings/mcp.json` (workspace) or `~/.kiro/settings/mcp.json` (user-level). Open via command palette: **Kiro: Open workspace MCP config**: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp", + "args": [], + "disabled": false, + "autoApprove": [] + } + } +} +``` + +#### LM Studio + +LM Studio follows Cursor's `mcp.json` notation. The config file is at `~/.lmstudio/mcp.json` (macOS/Linux) or `%USERPROFILE%\.lmstudio\mcp.json` (Windows). Open via the **Program** tab → **Install → Edit mcp.json**: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### opencode + +Edit `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global). Add under the `mcp` key: + +```json +{ + "mcp": { + "hebb": { + "type": "local", + "command": ["/absolute/path/to/hebb-mcp"], + "enabled": true + } + } +} +``` + +Or use the CLI: `opencode mcp add` → name: `hebb`, type: `local`, command: `/absolute/path/to/hebb-mcp`. + +#### VS Code (native MCP) + +VS Code (1.102+) supports MCP natively. Edit `.vscode/mcp.json` (workspace) or open via command palette: **MCP: Open User Configuration**. Note: VS Code uses `"servers"` (not `"mcpServers"`): + +```json +{ + "servers": { + "hebb": { + "type": "stdio", + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +Or from the command line: ```bash -codex mcp add hebb -- "$(which hebb-mcp)" +code --add-mcp '{"name":"hebb","command":"/absolute/path/to/hebb-mcp"}' +``` + +#### Warp + +Warp supports MCP servers via Settings → MCP (GUI). Add a new stdio server: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} ``` -### Claude Desktop +#### Windsurf -Add to `claude_desktop_config.json` (typically at `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS). Claude Desktop is a GUI app, so the **absolute path** to `hebb-mcp` (from `which hebb-mcp`) is required: +Edit `~/.codeium/windsurf/mcp_config.json` (macOS/Linux) or `%USERPROFILE%\.codeium\windsurf\mcp_config.json` (Windows). Open via command palette: **Windsurf: Configure MCP Servers**: ```json { @@ -102,9 +273,18 @@ Add to `claude_desktop_config.json` (typically at `~/Library/Application Support } ``` -### Cursor +### Other clients + +The following clients support MCP but have UI-only or evolving configuration surfaces. Use the standard `mcpServers` JSON shape and paste it into the client's MCP settings UI: + +| Client | Config location | Notes | +|--------|----------------|-------| +| **Antigravity** | Editor MCP settings (UI) | Google's agentic IDE; configure via Settings → MCP | +| **Factory** | Factory MCP config (UI) | Confirm format in Factory docs | +| **Junie (JetBrains)** | IDE MCP settings (UI) | JetBrains AI assistant; Settings → Tools → MCP | +| **Qodo Gen** | IDE plugin MCP config (UI) | VS Code extension; configure via plugin settings | -Open **Settings → Features → MCP** and add (Cursor is a GUI app — use the absolute path from `which hebb-mcp`): +For each, add: ```json { @@ -119,7 +299,7 @@ Open **Settings → Features → MCP** and add (Cursor is a GUI app — use the ## How It Works ``` -Claude Code / Codex / Cursor +Claude Code / Codex / Cursor / Windsurf / VS Code / ... │ (stdio) v hebb-mcp (MCP server) diff --git a/repo_pages/zh/guide/mcp-integration.md b/repo_pages/zh/guide/mcp-integration.md index a99a8b3..436dbab 100644 --- a/repo_pages/zh/guide/mcp-integration.md +++ b/repo_pages/zh/guide/mcp-integration.md @@ -1,10 +1,10 @@ --- -description: "通过 MCP 把 Claude Code、Codex、Cursor、Claude Desktop 接入 AI 智能体长期记忆:写入、向量加关键词混合检索召回,并触发记忆巩固。" +description: "通过 MCP 把 Claude Code、Codex、Cursor、Windsurf、VS Code 等 15+ 客户端接入 AI 智能体长期记忆:写入、向量加关键词混合检索召回,并触发记忆巩固。" --- # MCP 集成 -Hebb Mind 提供 MCP (Model Context Protocol) 服务,将记忆操作暴露为工具。Claude Code、Codex、Cursor 等 MCP 客户端可以直接使用。 +Hebb Mind 提供 MCP (Model Context Protocol) 服务,将记忆操作暴露为工具。任何兼容 MCP 的客户端都可以直接使用。下面是完整的快速接入矩阵 —— 找到你的客户端,粘贴配置即可。 ## 前提条件 @@ -24,27 +24,76 @@ hebb service install # 注册后台服务(默认用户级,无需管理员 | `consolidate` | 触发记忆巩固 | 无 | | `ingest_conversation` | 摄入一段对话导出(Claude Code JSONL / ChatGPT JSON / 纯文本)—— 自动识别格式、规整每一轮、逐轮存储 | `content`, `format_hint?`, `importance?` | +## 快速接入矩阵 + ::: tip 关于 `command` 路径 -下面所有 `.mcp.json` / `claude_desktop_config.json` 示例为简洁起见写的是裸 `hebb-mcp`。**对桌面 GUI 应用(Claude Desktop、Cursor)以及由 launchd 拉起的进程,请改填绝对路径** —— 这类环境的 `PATH` 往往不含 pipx 的 bin 目录,裸命令会静默启动失败。先用 `which hebb-mcp`(Windows:`where hebb-mcp`)查出路径,例如 `/Users/you/.local/bin/hebb-mcp`。或者直接走 `hebb claude-code install` / `hebb codex install`,安装器会自动解析绝对路径。 +下面所有示例中的 `/absolute/path/to/hebb-mcp` 是占位符。**对桌面 GUI 应用(Claude Desktop、Cursor、Windsurf、LM Studio 等)以及由 launchd 拉起的进程,必须改填绝对路径** —— 这类环境的 `PATH` 往往不含 pipx 的 bin 目录,裸命令会静默启动失败。先用 `which hebb-mcp`(Windows:`where hebb-mcp`)查出路径。或者直接走 `hebb claude-code install` / `hebb codex install`,安装器会自动解析绝对路径。 ::: -## 配置 +如果 Hebb Mind 服务运行在远程主机或非默认地址,添加 `env` 块设置 `HEBB_URL`: + +```json +"env": { "HEBB_URL": "http://192.168.1.100:8321" } +``` + +### 有一键安装器的客户端 + +#### Claude Code + +```bash +hebb claude-code install --scope user # hooks + MCP,自动解析绝对路径 +``` -MCP 服务自动从 `hebb.json` 发现服务地址。大多数情况下无需任何配置,只需添加命令即可。 +仅 MCP(项目级 `.mcp.json` 或全局 `~/.claude.json`): + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` -如果服务运行在远程主机或非默认地址,可通过 `HEBB_URL` 环境变量指定: +#### Codex -### Claude Code +```bash +hebb codex install # 项目级 MCP + 生命周期 hooks(默认) +codex mcp list +``` -推荐: +Codex 原生命令: ```bash -hebb claude-code install --scope user +codex mcp add hebb -- "$(which hebb-mcp)" ``` -仅 MCP: +### 通过配置接入的客户端 -在项目目录下创建 `.mcp.json`(把 `/absolute/path/to/hebb-mcp` 替换为 `which hebb-mcp` 的输出): +#### Amp + +Amp CLI 通过 `amp mcp add` 命令注册 MCP 服务器,配置写入 `.amp/settings.json`(项目级)或 `~/.config/amp/settings.json`(全局): + +```bash +amp mcp add hebb -- /absolute/path/to/hebb-mcp +``` + +或直接编辑 `.amp/settings.json`: + +```json +{ + "amp.mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### Claude Desktop + +在 `claude_desktop_config.json`(macOS:`~/Library/Application Support/Claude/claude_desktop_config.json`;Windows:`%APPDATA%\Claude\claude_desktop_config.json`)中添加。Claude Desktop 是 GUI 应用,**必须**填绝对路径: ```json { @@ -56,41 +105,163 @@ hebb claude-code install --scope user } ``` -或在全局配置 `~/.claude.json` 中添加相同内容。 +#### Cline -如果服务运行在非默认地址,可以显式指定: +Cline 的 MCP 配置有两个位置:Cline CLI 用 `~/.cline/mcp.json`;VS Code 扩展通过 Cline 面板 → MCP Servers 图标 → Configure 标签 → Configure MCP Servers 按钮打开配置文件。添加: ```json { "mcpServers": { "hebb": { "command": "/absolute/path/to/hebb-mcp", - "env": { - "HEBB_URL": "http://192.168.1.100:8321" - } + "disabled": false, + "autoApprove": [] } } } ``` -### Codex +#### Copilot (VS Code) -推荐: +VS Code 中的 Copilot 使用与 VS Code 原生 MCP 相同的 `.vscode/mcp.json`(见下方)。也可以通过命令面板添加: -```bash -hebb codex install # 项目级 MCP + 生命周期 hooks(默认) -codex mcp list +```text +MCP: Add Server → stdio → name: hebb → command: /absolute/path/to/hebb-mcp +``` + +#### Cursor + +编辑 `~/.cursor/mcp.json`(全局)或 `.cursor/mcp.json`(项目级)。通过 **Settings → Tools & MCP → New MCP Server** 打开,或直接创建文件: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### Gemini CLI + +编辑 `~/.gemini/settings.json`,添加 `mcpServers` 条目: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} ``` -Codex 原生命令(建议传 `hebb-mcp` 的绝对路径): +#### Goose + +编辑 `~/.config/goose/config.yaml`(macOS/Linux)或 `%APPDATA%\Block\goose\config\config.yaml`(Windows),在 `extensions` 下添加: + +```yaml +extensions: + hebb: + type: stdio + name: hebb + enabled: true + cmd: /absolute/path/to/hebb-mcp + args: [] + envs: {} + timeout: 300 +``` + +或使用 CLI 向导:`goose configure` → Add Extension → Stdio Extension → 名称: `hebb`,命令: `/absolute/path/to/hebb-mcp`。 + +#### Kiro + +编辑 `.kiro/settings/mcp.json`(工作区级)或 `~/.kiro/settings/mcp.json`(用户级)。通过命令面板 **Kiro: Open workspace MCP config** 打开: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp", + "args": [], + "disabled": false, + "autoApprove": [] + } + } +} +``` + +#### LM Studio + +LM Studio 采用与 Cursor 相同的 `mcp.json` 格式。配置文件位于 `~/.lmstudio/mcp.json`(macOS/Linux)或 `%USERPROFILE%\.lmstudio\mcp.json`(Windows)。通过 **Program** 标签 → **Install → Edit mcp.json** 打开: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +#### opencode + +编辑 `opencode.json`(项目级)或 `~/.config/opencode/opencode.json`(全局),在 `mcp` 键下添加: + +```json +{ + "mcp": { + "hebb": { + "type": "local", + "command": ["/absolute/path/to/hebb-mcp"], + "enabled": true + } + } +} +``` + +或使用 CLI:`opencode mcp add` → 名称: `hebb`,类型: `local`,命令: `/absolute/path/to/hebb-mcp`。 + +#### VS Code(原生 MCP) + +VS Code(1.102+)原生支持 MCP。编辑 `.vscode/mcp.json`(工作区级)或通过命令面板 **MCP: Open User Configuration** 打开。注意:VS Code 使用 `"servers"`(不是 `"mcpServers"`): + +```json +{ + "servers": { + "hebb": { + "type": "stdio", + "command": "/absolute/path/to/hebb-mcp" + } + } +} +``` + +或从命令行添加: ```bash -codex mcp add hebb -- "$(which hebb-mcp)" +code --add-mcp '{"name":"hebb","command":"/absolute/path/to/hebb-mcp"}' +``` + +#### Warp + +Warp 通过 `Settings → MCP`(GUI)支持 MCP 服务器。添加新的 stdio 服务器: + +```json +{ + "mcpServers": { + "hebb": { + "command": "/absolute/path/to/hebb-mcp" + } + } +} ``` -### Claude Desktop +#### Windsurf -在 `claude_desktop_config.json`(macOS 通常位于 `~/Library/Application Support/Claude/claude_desktop_config.json`)中添加。Claude Desktop 是 GUI 应用,因此**必须**填 `hebb-mcp` 的绝对路径(取自 `which hebb-mcp`): +编辑 `~/.codeium/windsurf/mcp_config.json`(macOS/Linux)或 `%USERPROFILE%\.codeium\windsurf\mcp_config.json`(Windows)。通过命令面板 **Windsurf: Configure MCP Servers** 打开: ```json { @@ -102,9 +273,18 @@ codex mcp add hebb -- "$(which hebb-mcp)" } ``` -### Cursor +### 其他客户端 + +以下客户端支持 MCP,但配置界面仅限 UI 或仍在演进中。使用标准的 `mcpServers` JSON 格式,粘贴到客户端的 MCP 设置界面: -打开 **Settings → Features → MCP**,添加(Cursor 是 GUI 应用 —— 请用 `which hebb-mcp` 给出的绝对路径): +| 客户端 | 配置位置 | 说明 | +|--------|---------|------| +| **Antigravity** | 编辑器 MCP 设置(UI) | Google 的智能体 IDE;通过 `Settings → MCP` 配置 | +| **Factory** | Factory MCP 配置(UI) | 参考 Factory 文档确认格式 | +| **Junie (JetBrains)** | IDE MCP 设置(UI) | JetBrains AI 助手;`Settings → Tools → MCP` | +| **Qodo Gen** | IDE 插件 MCP 配置(UI) | VS Code 扩展;通过插件设置配置 | + +每个客户端添加: ```json { @@ -119,7 +299,7 @@ codex mcp add hebb -- "$(which hebb-mcp)" ## 工作原理 ``` -Claude Code / Codex / Cursor +Claude Code / Codex / Cursor / Windsurf / VS Code / ... │ (stdio) v hebb-mcp (MCP 服务) @@ -149,3 +329,5 @@ hebb mcp serve 1. **存储上下文**:「记住用户偏好 TypeScript 而不是 JavaScript」 2. **后续召回**:「用户的语言偏好是什么?」 3. **整理记忆**:触发巩固,将记忆分类到对应分区 + +智能体在对话中自然地使用这些工具,无需显式 API 调用。 diff --git a/src/hebb/cli/main.py b/src/hebb/cli/main.py index 4e94bdc..75b6394 100644 --- a/src/hebb/cli/main.py +++ b/src/hebb/cli/main.py @@ -18,8 +18,12 @@ from hebb.cli.commands.setup import setup_cmd from hebb.cli.commands.status import status_cmd from hebb.cli.commands.upgrade import upgrade_cmd +from hebb.integrations.amp.cli import amp from hebb.integrations.claude_code.cli import cc from hebb.integrations.codex.cli import codex +from hebb.integrations.gemini_cli.cli import gemini +from hebb.integrations.goose.cli import goose +from hebb.integrations.opencode.cli import opencode @click.group() @@ -43,6 +47,10 @@ def main() -> None: main.add_command(upgrade_cmd, "upgrade") main.add_command(cc) main.add_command(codex) +main.add_command(gemini) +main.add_command(goose) +main.add_command(opencode) +main.add_command(amp) # Hidden internal entrypoint invoked by the OS service manager (launchd / # systemd / Task Scheduler). Not part of the public CLI surface. main.add_command(serve_cmd, "_serve") diff --git a/src/hebb/integrations/_json_config.py b/src/hebb/integrations/_json_config.py new file mode 100644 index 0000000..0db6d33 --- /dev/null +++ b/src/hebb/integrations/_json_config.py @@ -0,0 +1,115 @@ +"""Shared helpers for JSON-based MCP client installers. + +Most MCP clients (Gemini CLI, Cline, Cursor, LM Studio, …) store their +server configuration in a JSON file with a ``mcpServers`` top-level key. +This module factors out the common read / upsert / remove logic so each +client installer only needs to supply the config *path* and the *key name* +(which is almost always ``"mcpServers"``). +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +import click + + +def load_json(path: Path) -> dict[str, Any]: + """Load a JSON config file, returning ``{}`` if it does not exist. + + Args: + path: Configuration file path. + + Returns: + Parsed JSON object (top-level dict). + + Raises: + click.ClickException: If the file exists but cannot be parsed. + """ + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise click.ClickException(f"Cannot read {path}: {exc}") from exc + if not isinstance(data, dict): + raise click.ClickException(f"{path} must contain a JSON object at the top level") + return data + + +def atomic_write(path: Path, content: str) -> None: + """Atomically replace a UTF-8 text file, creating its parent dir. + + Args: + path: Destination file. + content: Complete replacement content. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + os.replace(temp_name, path) + except Exception: + try: + os.unlink(temp_name) + except OSError: + pass + raise + + +def upsert_server( + path: Path, + key: str, + server_name: str, + entry: dict[str, Any], +) -> bool: + """Insert or replace an MCP server entry in a JSON config file. + + Args: + path: Configuration file path. + key: Top-level key holding the servers mapping (e.g. ``"mcpServers"``). + server_name: Name of the server entry to upsert. + entry: Server configuration dict (``command``, ``args``, ``env`` …). + + Returns: + ``True`` if the file was written, ``False`` if it was already correct. + """ + data = load_json(path) + servers = data.get(key) + if not isinstance(servers, dict): + servers = {} + data[key] = servers + if servers.get(server_name) == entry: + return False + servers[server_name] = entry + atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + return True + + +def remove_server( + path: Path, + key: str, + server_name: str, +) -> bool: + """Remove an MCP server entry from a JSON config file. + + Args: + path: Configuration file path. + key: Top-level key holding the servers mapping. + server_name: Name of the server entry to remove. + + Returns: + ``True`` if the file was changed, ``False`` if the entry was absent. + """ + data = load_json(path) + servers = data.get(key) + if not isinstance(servers, dict) or server_name not in servers: + return False + del servers[server_name] + atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n") + return True diff --git a/src/hebb/integrations/amp/__init__.py b/src/hebb/integrations/amp/__init__.py new file mode 100644 index 0000000..83b80fb --- /dev/null +++ b/src/hebb/integrations/amp/__init__.py @@ -0,0 +1 @@ +"""Amp CLI integration for Hebb Mind MCP.""" diff --git a/src/hebb/integrations/amp/cli.py b/src/hebb/integrations/amp/cli.py new file mode 100644 index 0000000..3c59991 --- /dev/null +++ b/src/hebb/integrations/amp/cli.py @@ -0,0 +1,40 @@ +"""Amp CLI integration commands.""" + +from __future__ import annotations + +import click + + +@click.group("amp") +def amp() -> None: + """Amp CLI integration — MCP server registration.""" + + +@amp.command("install") +@click.option( + "--scope", + type=click.Choice(["project", "user"]), + default="user", + show_default=True, + help="Where to install: 'project' writes .amp/settings.json; 'user' writes global config.", +) +def install(scope: str) -> None: + """Install Hebb Mind MCP into Amp CLI.""" + from hebb.integrations.amp.install import handle + + handle(scope) + + +@amp.command("uninstall") +@click.option( + "--scope", + type=click.Choice(["project", "user"]), + default="user", + show_default=True, + help="Where to remove from.", +) +def uninstall(scope: str) -> None: + """Remove Hebb Mind MCP from Amp CLI.""" + from hebb.integrations.amp.uninstall import handle + + handle(scope) diff --git a/src/hebb/integrations/amp/install.py b/src/hebb/integrations/amp/install.py new file mode 100644 index 0000000..7ea1d73 --- /dev/null +++ b/src/hebb/integrations/amp/install.py @@ -0,0 +1,63 @@ +"""Install Hebb Mind MCP into Amp CLI settings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click + +from hebb.integrations._json_config import upsert_server +from hebb.utils.cli_paths import hebb_mcp_command, shell_quote + +# Amp uses "amp.mcpServers" as the key (not "mcpServers") +SERVER_KEY = "amp.mcpServers" +SERVER_NAME = "hebb" + + +def config_path(scope: str) -> Path: + """Resolve the Amp settings file path. + + Args: + scope: ``"project"`` (``.amp/settings.json`` in cwd) or ``"user"`` + (``~/.config/amp/settings.json``). + + Returns: + Path to the Amp settings file. + """ + if scope == "project": + return Path.cwd() / ".amp" / "settings.json" + if scope == "user": + return Path.home() / ".config" / "amp" / "settings.json" + raise ValueError(f"Unsupported Amp scope: {scope}") + + +def build_entry(mcp_argv: list[str]) -> dict[str, Any]: + """Build the Amp MCP server entry for Hebb.""" + entry: dict[str, Any] = {"command": mcp_argv[0]} + if len(mcp_argv) > 1: + entry["args"] = mcp_argv[1:] + return entry + + +def handle(scope: str = "user") -> None: + """Install Hebb Mind MCP into Amp CLI. + + Args: + scope: ``"project"`` or ``"user"`` (default: ``"user"``). + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + mcp_argv = hebb_mcp_command() + entry = build_entry(mcp_argv) + path = config_path(scope) + + upsert_server(path, SERVER_KEY, SERVER_NAME, entry) + + click.secho(f"Installed Hebb Mind for Amp ({scope}).", fg="green") + click.echo(f" Config: {path}") + click.echo(f" Server command: {shell_quote(mcp_argv)}") + if scope == "project": + click.echo(" Settings merge with global Amp config when you run amp in this project.") + click.echo("Start a new Amp session to activate the integration.") diff --git a/src/hebb/integrations/amp/uninstall.py b/src/hebb/integrations/amp/uninstall.py new file mode 100644 index 0000000..fe5b261 --- /dev/null +++ b/src/hebb/integrations/amp/uninstall.py @@ -0,0 +1,28 @@ +"""Remove Hebb Mind MCP from Amp CLI settings.""" + +from __future__ import annotations + +import click + +from hebb.integrations._json_config import remove_server +from hebb.integrations.amp.install import SERVER_KEY, SERVER_NAME, config_path + + +def handle(scope: str = "user") -> None: + """Remove Hebb Mind MCP from Amp CLI. + + Args: + scope: ``"project"`` or ``"user"``. + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + path = config_path(scope) + changed = remove_server(path, SERVER_KEY, SERVER_NAME) + + if changed: + click.secho(f"Removed Hebb Mind from Amp ({scope}).", fg="green") + else: + click.echo(f"Hebb Mind was not configured for Amp ({scope}).") + click.echo(f" Config: {path}") + click.echo("Start a new Amp session to apply the change.") diff --git a/src/hebb/integrations/gemini_cli/__init__.py b/src/hebb/integrations/gemini_cli/__init__.py new file mode 100644 index 0000000..5c5bdb0 --- /dev/null +++ b/src/hebb/integrations/gemini_cli/__init__.py @@ -0,0 +1 @@ +"""Gemini CLI integration for Hebb Mind MCP.""" diff --git a/src/hebb/integrations/gemini_cli/cli.py b/src/hebb/integrations/gemini_cli/cli.py new file mode 100644 index 0000000..72a4865 --- /dev/null +++ b/src/hebb/integrations/gemini_cli/cli.py @@ -0,0 +1,26 @@ +"""Gemini CLI integration commands.""" + +from __future__ import annotations + +import click + + +@click.group("gemini") +def gemini() -> None: + """Gemini CLI integration — MCP server registration.""" + + +@gemini.command("install") +def install() -> None: + """Install Hebb Mind MCP into Gemini CLI settings.json.""" + from hebb.integrations.gemini_cli.install import handle + + handle() + + +@gemini.command("uninstall") +def uninstall() -> None: + """Remove Hebb Mind MCP from Gemini CLI settings.json.""" + from hebb.integrations.gemini_cli.uninstall import handle + + handle() diff --git a/src/hebb/integrations/gemini_cli/install.py b/src/hebb/integrations/gemini_cli/install.py new file mode 100644 index 0000000..b8e7473 --- /dev/null +++ b/src/hebb/integrations/gemini_cli/install.py @@ -0,0 +1,48 @@ +"""Install Hebb Mind MCP into Gemini CLI settings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click + +from hebb.integrations._json_config import upsert_server +from hebb.utils.cli_paths import hebb_mcp_command, shell_quote + +SERVER_KEY = "mcpServers" +SERVER_NAME = "hebb" + + +def config_path() -> Path: + """Resolve the Gemini CLI settings file path.""" + return Path.home() / ".gemini" / "settings.json" + + +def build_entry(mcp_argv: list[str]) -> dict[str, Any]: + """Build the Gemini CLI MCP server entry for Hebb.""" + entry: dict[str, Any] = {"command": mcp_argv[0]} + if len(mcp_argv) > 1: + entry["args"] = mcp_argv[1:] + return entry + + +def handle() -> None: + """Install Hebb Mind MCP into Gemini CLI. + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + mcp_argv = hebb_mcp_command() + entry = build_entry(mcp_argv) + path = config_path() + + written = upsert_server(path, SERVER_KEY, SERVER_NAME, entry) + + click.secho("Installed Hebb Mind for Gemini CLI.", fg="green") + click.echo(f" Config: {path}") + click.echo(f" Server command: {shell_quote(mcp_argv)}") + if not written: + click.echo(" (already configured — entry refreshed)") + click.echo("Verify with: gemini /mcp") + click.echo("Start a new Gemini CLI session to activate the integration.") diff --git a/src/hebb/integrations/gemini_cli/uninstall.py b/src/hebb/integrations/gemini_cli/uninstall.py new file mode 100644 index 0000000..bdf5160 --- /dev/null +++ b/src/hebb/integrations/gemini_cli/uninstall.py @@ -0,0 +1,25 @@ +"""Remove Hebb Mind MCP from Gemini CLI settings.""" + +from __future__ import annotations + +import click + +from hebb.integrations._json_config import remove_server +from hebb.integrations.gemini_cli.install import SERVER_KEY, SERVER_NAME, config_path + + +def handle() -> None: + """Remove Hebb Mind MCP from Gemini CLI. + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + path = config_path() + changed = remove_server(path, SERVER_KEY, SERVER_NAME) + + if changed: + click.secho("Removed Hebb Mind from Gemini CLI.", fg="green") + else: + click.echo("Hebb Mind was not configured for Gemini CLI.") + click.echo(f" Config: {path}") + click.echo("Start a new Gemini CLI session to apply the change.") diff --git a/src/hebb/integrations/goose/__init__.py b/src/hebb/integrations/goose/__init__.py new file mode 100644 index 0000000..18f08f1 --- /dev/null +++ b/src/hebb/integrations/goose/__init__.py @@ -0,0 +1 @@ +"""Goose integration for Hebb Mind MCP.""" diff --git a/src/hebb/integrations/goose/cli.py b/src/hebb/integrations/goose/cli.py new file mode 100644 index 0000000..b92d0b7 --- /dev/null +++ b/src/hebb/integrations/goose/cli.py @@ -0,0 +1,26 @@ +"""Goose integration commands.""" + +from __future__ import annotations + +import click + + +@click.group("goose") +def goose() -> None: + """Goose integration — MCP server registration.""" + + +@goose.command("install") +def install() -> None: + """Install Hebb Mind MCP into Goose config.yaml.""" + from hebb.integrations.goose.install import handle + + handle() + + +@goose.command("uninstall") +def uninstall() -> None: + """Remove Hebb Mind MCP from Goose config.yaml.""" + from hebb.integrations.goose.uninstall import handle + + handle() diff --git a/src/hebb/integrations/goose/install.py b/src/hebb/integrations/goose/install.py new file mode 100644 index 0000000..e2f81cb --- /dev/null +++ b/src/hebb/integrations/goose/install.py @@ -0,0 +1,160 @@ +"""Install Hebb Mind MCP into Goose config.yaml.""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +import click + +from hebb.utils.cli_paths import hebb_mcp_command, shell_quote + +EXTENSION_NAME = "hebb" + + +def config_path() -> Path: + """Resolve the Goose configuration file path.""" + return Path.home() / ".config" / "goose" / "config.yaml" + + +def _build_yaml_block(mcp_argv: list[str]) -> str: + """Build the Goose YAML extension entry for Hebb. + + Goose stores MCP servers under ``extensions`` with ``type: stdio``. + The command goes in ``cmd``, arguments in ``args``. + """ + cmd = mcp_argv[0] + args = mcp_argv[1:] + if args: + args_block = " args:\n" + "\n".join(f" - {a}" for a in args) + "\n" + else: + args_block = " args: []\n" + return ( + f" {EXTENSION_NAME}:\n" + f" type: stdio\n" + f" name: {EXTENSION_NAME}\n" + f" enabled: true\n" + f" cmd: {cmd}\n" + f"{args_block}" + f" envs: {{}}\n" + f" timeout: 300\n" + ) + + +def _remove_hebb_block(text: str) -> str: + """Remove the Hebb extension block from Goose YAML. + + Args: + text: Existing config.yaml content. + + Returns: + YAML content without the Hebb extension entry. + """ + lines = text.splitlines(keepends=True) + output: list[str] = [] + skipping = False + for line in lines: + stripped = line.rstrip() + # Detect start of hebb block: " hebb:" at extension indent level + if stripped == f" {EXTENSION_NAME}:": + skipping = True + continue + if skipping: + # Hebb block lines are indented with 4+ spaces (nested under hebb:) + # or blank. Stop skipping when we hit a line with <= 2 spaces indent + # (sibling extension or top-level key). + if line.strip() and not line.startswith(" "): + skipping = False + output.append(line) + # else: still in hebb block, skip + else: + output.append(line) + return "".join(output) + + +def atomic_write(path: Path, content: str) -> None: + """Atomically replace a UTF-8 text file, creating its parent dir.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as stream: + stream.write(content) + os.replace(temp_name, path) + except Exception: + try: + os.unlink(temp_name) + except OSError: + pass + raise + + +def handle() -> None: + """Install Hebb Mind MCP into Goose. + + Raises: + click.ClickException: If the config file has an unsupported inline + ``extensions:`` key (e.g. ``extensions: {}``) that cannot be + safely extended. + """ + mcp_argv = hebb_mcp_command() + path = config_path() + + existing = path.read_text(encoding="utf-8") if path.exists() else "" + cleaned = _remove_hebb_block(existing) + + block = _build_yaml_block(mcp_argv) + + # Match only root-level (unindented) "extensions:" headers. + # A root-level block header may have a trailing comment: "extensions: # managed" + # An inline form like "extensions: {}" has non-comment content after the colon. + import re + + root_extensions_lines = [ + line for line in cleaned.splitlines() if not line.startswith(" ") and not line.startswith("\t") + ] + has_extensions_block = any(re.match(r"^extensions:\s*(?:#.*)?$", line) for line in root_extensions_lines) + has_extensions_inline = any( + line.lstrip().startswith("extensions:") + and not re.match(r"^extensions:\s*(?:#.*)?$", line) + and not line.startswith(" ") + and not line.startswith("\t") + for line in cleaned.splitlines() + ) + + if has_extensions_inline and not has_extensions_block: + raise click.ClickException( + f"Goose config has an inline 'extensions:' key that cannot be safely " + f"extended. Please convert it to block style (a line with just " + f"'extensions:') in {path}." + ) + + if not has_extensions_block: + if cleaned and not cleaned.endswith("\n"): + cleaned += "\n" + cleaned += "extensions:\n" + block + else: + # Insert hebb block right after the root-level "extensions:" line + lines = cleaned.splitlines(keepends=True) + output: list[str] = [] + inserted = False + for line in lines: + output.append(line) + if not inserted and re.match(r"^extensions:\s*(?:#.*)?$", line): + output.append(block) + inserted = True + if not inserted: + # Fallback: should not happen given has_extensions_block check + if cleaned and not cleaned.endswith("\n"): + cleaned += "\n" + cleaned += "extensions:\n" + block + else: + cleaned = "".join(output) + + atomic_write(path, cleaned) + + click.secho("Installed Hebb Mind for Goose.", fg="green") + click.echo(f" Config: {path}") + click.echo(f" Server command: {shell_quote(mcp_argv)}") + click.echo("Verify with: goose info -v") + click.echo("Start a new Goose session to activate the integration.") diff --git a/src/hebb/integrations/goose/uninstall.py b/src/hebb/integrations/goose/uninstall.py new file mode 100644 index 0000000..cf6465f --- /dev/null +++ b/src/hebb/integrations/goose/uninstall.py @@ -0,0 +1,31 @@ +"""Remove Hebb Mind MCP from Goose config.yaml.""" + +from __future__ import annotations + +import click + +from hebb.integrations.goose.install import _remove_hebb_block, atomic_write, config_path + + +def handle() -> None: + """Remove Hebb Mind MCP from Goose. + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + path = config_path() + if not path.exists(): + click.echo("Hebb Mind was not configured for Goose.") + return + + existing = path.read_text(encoding="utf-8") + cleaned = _remove_hebb_block(existing) + + if cleaned == existing: + click.echo("Hebb Mind was not configured for Goose.") + return + + atomic_write(path, cleaned) + click.secho("Removed Hebb Mind from Goose.", fg="green") + click.echo(f" Config: {path}") + click.echo("Start a new Goose session to apply the change.") diff --git a/src/hebb/integrations/opencode/__init__.py b/src/hebb/integrations/opencode/__init__.py new file mode 100644 index 0000000..ad71392 --- /dev/null +++ b/src/hebb/integrations/opencode/__init__.py @@ -0,0 +1 @@ +"""opencode integration for Hebb Mind MCP.""" diff --git a/src/hebb/integrations/opencode/cli.py b/src/hebb/integrations/opencode/cli.py new file mode 100644 index 0000000..9615642 --- /dev/null +++ b/src/hebb/integrations/opencode/cli.py @@ -0,0 +1,40 @@ +"""opencode integration commands.""" + +from __future__ import annotations + +import click + + +@click.group("opencode") +def opencode() -> None: + """opencode integration — MCP server registration.""" + + +@opencode.command("install") +@click.option( + "--scope", + type=click.Choice(["project", "user"]), + default="user", + show_default=True, + help="Where to install: 'project' writes opencode.json in cwd; 'user' writes the global config.", +) +def install(scope: str) -> None: + """Install Hebb Mind MCP into opencode.""" + from hebb.integrations.opencode.install import handle + + handle(scope) + + +@opencode.command("uninstall") +@click.option( + "--scope", + type=click.Choice(["project", "user"]), + default="user", + show_default=True, + help="Where to remove from.", +) +def uninstall(scope: str) -> None: + """Remove Hebb Mind MCP from opencode.""" + from hebb.integrations.opencode.uninstall import handle + + handle(scope) diff --git a/src/hebb/integrations/opencode/install.py b/src/hebb/integrations/opencode/install.py new file mode 100644 index 0000000..02c70b0 --- /dev/null +++ b/src/hebb/integrations/opencode/install.py @@ -0,0 +1,66 @@ +"""Install Hebb Mind MCP into opencode configuration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import click + +from hebb.integrations._json_config import upsert_server +from hebb.utils.cli_paths import hebb_mcp_command, shell_quote + +SERVER_KEY = "mcp" +SERVER_NAME = "hebb" + + +def config_path(scope: str) -> Path: + """Resolve the opencode configuration path for an installation scope. + + Args: + scope: ``"project"`` (``opencode.json`` in cwd) or ``"user"`` + (``~/.config/opencode/opencode.json``). + + Returns: + Path to the opencode config file. + """ + if scope == "project": + return Path.cwd() / "opencode.json" + if scope == "user": + return Path.home() / ".config" / "opencode" / "opencode.json" + raise ValueError(f"Unsupported opencode scope: {scope}") + + +def build_entry(mcp_argv: list[str]) -> dict[str, Any]: + """Build the opencode MCP server entry for Hebb. + + opencode uses ``command`` as a flat array (command + args combined), + not separate ``command`` + ``args`` fields like most other clients. + """ + return { + "type": "local", + "command": mcp_argv, + "enabled": True, + } + + +def handle(scope: str = "user") -> None: + """Install Hebb Mind MCP into opencode. + + Args: + scope: ``"project"`` or ``"user"`` (default: ``"user"``). + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + mcp_argv = hebb_mcp_command() + entry = build_entry(mcp_argv) + path = config_path(scope) + + upsert_server(path, SERVER_KEY, SERVER_NAME, entry) + + click.secho(f"Installed Hebb Mind for opencode ({scope}).", fg="green") + click.echo(f" Config: {path}") + click.echo(f" Server command: {shell_quote(mcp_argv)}") + click.echo("Verify with: opencode mcp list") + click.echo("Start a new opencode session to activate the integration.") diff --git a/src/hebb/integrations/opencode/uninstall.py b/src/hebb/integrations/opencode/uninstall.py new file mode 100644 index 0000000..85ac04c --- /dev/null +++ b/src/hebb/integrations/opencode/uninstall.py @@ -0,0 +1,28 @@ +"""Remove Hebb Mind MCP from opencode configuration.""" + +from __future__ import annotations + +import click + +from hebb.integrations._json_config import remove_server +from hebb.integrations.opencode.install import SERVER_KEY, SERVER_NAME, config_path + + +def handle(scope: str = "user") -> None: + """Remove Hebb Mind MCP from opencode. + + Args: + scope: ``"project"`` or ``"user"``. + + Raises: + click.ClickException: If the config file exists but cannot be parsed. + """ + path = config_path(scope) + changed = remove_server(path, SERVER_KEY, SERVER_NAME) + + if changed: + click.secho(f"Removed Hebb Mind from opencode ({scope}).", fg="green") + else: + click.echo(f"Hebb Mind was not configured for opencode ({scope}).") + click.echo(f" Config: {path}") + click.echo("Start a new opencode session to apply the change.") diff --git a/tests/unit/integrations/test_mcp_client_installers.py b/tests/unit/integrations/test_mcp_client_installers.py new file mode 100644 index 0000000..b07ec08 --- /dev/null +++ b/tests/unit/integrations/test_mcp_client_installers.py @@ -0,0 +1,289 @@ +"""Tests for the new MCP client installers (Gemini CLI, Goose, opencode, Amp). + +Pattern follows test_codex_cli.py: monkeypatch paths and hebb_mcp_command, +invoke via Click's CliRunner, assert on generated config file content. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from click.testing import CliRunner + +from hebb.integrations.amp.cli import amp +from hebb.integrations.gemini_cli.cli import gemini +from hebb.integrations.goose.cli import goose +from hebb.integrations.opencode.cli import opencode + +# ── Gemini CLI ────────────────────────────────────────────────────────── + + +def test_gemini_install_creates_mcp_entry(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.config_path", + lambda: tmp_path / "settings.json", + ) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(gemini, ["install"]) + + assert result.exit_code == 0, result.output + cfg = json.loads((tmp_path / "settings.json").read_text()) + assert cfg["mcpServers"]["hebb"]["command"] == "/bin/hebb-mcp" + + +def test_gemini_install_is_idempotent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.config_path", + lambda: tmp_path / "settings.json", + ) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + CliRunner().invoke(gemini, ["install"]) + result = CliRunner().invoke(gemini, ["install"]) + + assert result.exit_code == 0, result.output + cfg = json.loads((tmp_path / "settings.json").read_text()) + assert cfg["mcpServers"]["hebb"]["command"] == "/bin/hebb-mcp" + + +def test_gemini_install_preserves_existing(monkeypatch, tmp_path: Path) -> None: + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"mcpServers": {"other": {"command": "/bin/other"}}, "theme": "dark"})) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.config_path", + lambda: settings, + ) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(gemini, ["install"]) + + assert result.exit_code == 0, result.output + cfg = json.loads(settings.read_text()) + assert cfg["mcpServers"]["other"]["command"] == "/bin/other" + assert cfg["mcpServers"]["hebb"]["command"] == "/bin/hebb-mcp" + assert cfg["theme"] == "dark" + + +def test_gemini_uninstall_removes_entry(monkeypatch, tmp_path: Path) -> None: + settings = tmp_path / "settings.json" + settings.write_text(json.dumps({"mcpServers": {"hebb": {"command": "/bin/hebb-mcp"}}})) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.config_path", + lambda: settings, + ) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.uninstall.config_path", + lambda: settings, + ) + + result = CliRunner().invoke(gemini, ["uninstall"]) + + assert result.exit_code == 0, result.output + cfg = json.loads(settings.read_text()) + assert "hebb" not in cfg.get("mcpServers", {}) + + +def test_gemini_uninstall_when_absent(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr( + "hebb.integrations.gemini_cli.install.config_path", + lambda: tmp_path / "settings.json", + ) + monkeypatch.setattr( + "hebb.integrations.gemini_cli.uninstall.config_path", + lambda: tmp_path / "settings.json", + ) + + result = CliRunner().invoke(gemini, ["uninstall"]) + + assert result.exit_code == 0, result.output + assert "not configured" in result.output.lower() + + +# ── opencode ──────────────────────────────────────────────────────────── + + +def test_opencode_install_user_scope(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "opencode.json" + monkeypatch.setattr( + "hebb.integrations.opencode.install.config_path", + lambda scope: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.opencode.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(opencode, ["install"]) + + assert result.exit_code == 0, result.output + data = json.loads(cfg.read_text()) + assert data["mcp"]["hebb"]["type"] == "local" + assert data["mcp"]["hebb"]["command"] == ["/bin/hebb-mcp"] + assert data["mcp"]["hebb"]["enabled"] is True + + +def test_opencode_install_preserves_existing(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "opencode.json" + cfg.write_text(json.dumps({"mcp": {"other": {"type": "remote", "url": "http://x"}}, "model": "test"})) + monkeypatch.setattr( + "hebb.integrations.opencode.install.config_path", + lambda scope: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.opencode.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(opencode, ["install"]) + + assert result.exit_code == 0, result.output + data = json.loads(cfg.read_text()) + assert data["mcp"]["other"]["url"] == "http://x" + assert data["mcp"]["hebb"]["command"] == ["/bin/hebb-mcp"] + assert data["model"] == "test" + + +def test_opencode_uninstall(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "opencode.json" + cfg.write_text(json.dumps({"mcp": {"hebb": {"command": ["/bin/hebb-mcp"]}}})) + monkeypatch.setattr( + "hebb.integrations.opencode.install.config_path", + lambda scope: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.opencode.uninstall.config_path", + lambda scope: cfg, + ) + + result = CliRunner().invoke(opencode, ["uninstall"]) + + assert result.exit_code == 0, result.output + data = json.loads(cfg.read_text()) + assert "hebb" not in data.get("mcp", {}) + + +# ── Amp ───────────────────────────────────────────────────────────────── + + +def test_amp_install_user_scope(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "settings.json" + monkeypatch.setattr( + "hebb.integrations.amp.install.config_path", + lambda scope: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.amp.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(amp, ["install"]) + + assert result.exit_code == 0, result.output + data = json.loads(cfg.read_text()) + assert data["amp.mcpServers"]["hebb"]["command"] == "/bin/hebb-mcp" + + +def test_amp_install_project_scope(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "hebb.integrations.amp.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(amp, ["install", "--scope", "project"]) + + assert result.exit_code == 0, result.output + cfg = tmp_path / ".amp" / "settings.json" + data = json.loads(cfg.read_text()) + assert data["amp.mcpServers"]["hebb"]["command"] == "/bin/hebb-mcp" + + +def test_amp_uninstall(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "settings.json" + cfg.write_text(json.dumps({"amp.mcpServers": {"hebb": {"command": "/bin/hebb-mcp"}}})) + monkeypatch.setattr( + "hebb.integrations.amp.install.config_path", + lambda scope: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.amp.uninstall.config_path", + lambda scope: cfg, + ) + + result = CliRunner().invoke(amp, ["uninstall"]) + + assert result.exit_code == 0, result.output + data = json.loads(cfg.read_text()) + assert "hebb" not in data.get("amp.mcpServers", {}) + + +# ── Goose (YAML) ──────────────────────────────────────────────────────── + + +def test_goose_install_creates_extension(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + monkeypatch.setattr( + "hebb.integrations.goose.install.config_path", + lambda: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.goose.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + result = CliRunner().invoke(goose, ["install"]) + + assert result.exit_code == 0, result.output + content = cfg.read_text() + assert "hebb:" in content + assert "type: stdio" in content + assert "/bin/hebb-mcp" in content + assert "enabled: true" in content + + +def test_goose_install_is_idempotent(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + monkeypatch.setattr( + "hebb.integrations.goose.install.config_path", + lambda: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.goose.install.hebb_mcp_command", + lambda: ["/bin/hebb-mcp"], + ) + + CliRunner().invoke(goose, ["install"]) + result = CliRunner().invoke(goose, ["install"]) + + assert result.exit_code == 0, result.output + assert cfg.read_text().count("hebb:") == 1 # no duplicate blocks + + +def test_goose_uninstall(monkeypatch, tmp_path: Path) -> None: + cfg = tmp_path / "config.yaml" + cfg.write_text("extensions:\n hebb:\n type: stdio\n cmd: /bin/hebb-mcp\n enabled: true\n") + monkeypatch.setattr( + "hebb.integrations.goose.install.config_path", + lambda: cfg, + ) + monkeypatch.setattr( + "hebb.integrations.goose.uninstall.config_path", + lambda: cfg, + ) + + result = CliRunner().invoke(goose, ["uninstall"]) + + assert result.exit_code == 0, result.output + content = cfg.read_text() + assert "hebb" not in content diff --git a/tests/unit/test_command_drift.py b/tests/unit/test_command_drift.py new file mode 100644 index 0000000..2bbc830 --- /dev/null +++ b/tests/unit/test_command_drift.py @@ -0,0 +1,206 @@ +"""S1 command-drift CI guard (issue #32). + +Asserts that every ``hebb `` reference in documentation and +generated client config resolves to a real Click command registered on +the root CLI. This catches stale docs / config after a command rename +or removal. + +Two surfaces are checked: + +1. **Documentation** — ``repo_pages/guide/mcp-integration.md`` and its + zh mirror. Extracts ``hebb [subcmd]`` patterns from code blocks + and inline code. + +2. **Generated config** — Codex and Claude Code installers emit hook + commands that reference ``hebb codex recall`` / ``hebb claude-code + stop`` etc. We build the hook config in-process and verify each + referenced command exists. + +The ``hebb-mcp`` entrypoint (``pyproject.toml`` console script) is also +verified to point at a real module. +""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path + +import pytest + +from hebb.cli.main import main as cli_main + +# ── Helpers ───────────────────────────────────────────────────────────── + +ROOT = Path(__file__).resolve().parents[2] + + +def _registered_commands() -> set[str]: + """Return the set of top-level command names registered on the CLI.""" + return set(cli_main.commands.keys()) + + +def _registered_subcommands(group_name: str) -> set[str]: + """Return subcommand names for a given group, or empty set if absent.""" + cmd = cli_main.commands.get(group_name) + if cmd is None: + return set() + # Click groups store sub-commands in .commands; single commands don't + if hasattr(cmd, "commands"): + return set(cmd.commands.keys()) + return set() + + +def _extract_hebb_commands_from_text(text: str) -> list[tuple[str, ...]]: + """Extract (cmd, *subcmds) tuples from text containing ``hebb ...``. + + Only matches inside code blocks or inline code to avoid false positives + from prose. Excludes JSON key-value patterns like ``"hebb":``. + """ + commands: list[tuple[str, ...]] = [] + + # Words that look like ``hebb `` but are actually JSON values, + # prose, or config keys — not CLI commands. + non_command_words = {"enabled", "true", "false", "null", "command", "args", "env"} + + # Match inside fenced code blocks + for block in re.findall(r"```[a-z]*\n(.*?)```", text, re.DOTALL): + for m in re.finditer(r"\bhebb\s+([a-z][-a-z]*(?:\s+[a-z][-a-z]*)?)", block): + parts = m.group(1).strip().split() + if parts[0] in non_command_words: + continue + commands.append(tuple(parts)) + + # Match inside inline code: `hebb xxx yyy` + for m in re.finditer(r"`hebb\s+([a-z][-a-z]*(?:\s+[a-z][-a-z]*)?)`", text): + parts = m.group(1).strip().split() + if parts[0] in non_command_words: + continue + commands.append(tuple(parts)) + + return commands + + +# ── Tests: CLI surface ────────────────────────────────────────────────── + + +class TestCLIRegistration: + """Verify expected commands are registered on the root CLI.""" + + EXPECTED_TOP_LEVEL = { + "amp", + "claude-code", + "codex", + "gemini", + "goose", + "opencode", + "mcp", + "setup", + "service", + } + + def test_expected_top_level_commands_exist(self) -> None: + registered = _registered_commands() + missing = self.EXPECTED_TOP_LEVEL - registered + assert not missing, f"Missing top-level commands: {missing}" + + @pytest.mark.parametrize( + "group,expected_subs", + [ + ("codex", {"install", "uninstall", "recall", "prompt", "stop"}), + ("claude-code", {"install", "uninstall", "recall", "prompt", "stop"}), + ("gemini", {"install", "uninstall"}), + ("goose", {"install", "uninstall"}), + ("opencode", {"install", "uninstall"}), + ("amp", {"install", "uninstall"}), + ], + ) + def test_subcommands_registered(self, group: str, expected_subs: set[str]) -> None: + actual = _registered_subcommands(group) + missing = expected_subs - actual + assert not missing, f"`hebb {group}` missing subcommands: {missing}" + + +# ── Tests: documentation drift ────────────────────────────────────────── + + +class TestDocCommandDrift: + """Ensure every ``hebb …`` in the MCP integration docs resolves to a + real CLI command.""" + + DOCS = [ + ROOT / "repo_pages" / "guide" / "mcp-integration.md", + ROOT / "repo_pages" / "zh" / "guide" / "mcp-integration.md", + ] + + @pytest.mark.parametrize("doc_path", DOCS, ids=lambda p: p.name) + def test_doc_commands_exist(self, doc_path: Path) -> None: + if not doc_path.exists(): + pytest.skip(f"{doc_path} not found") + text = doc_path.read_text(encoding="utf-8") + commands = _extract_hebb_commands_from_text(text) + + top_level = _registered_commands() + failures: list[str] = [] + + for parts in commands: + cmd = parts[0] + if cmd not in top_level: + failures.append(f" `hebb {' '.join(parts)}` — top-level '{cmd}' not registered") + continue + if len(parts) > 1: + sub = parts[1] + subs = _registered_subcommands(cmd) + if sub not in subs: + failures.append( + f" `hebb {' '.join(parts)}` — subcommand '{sub}' not registered " + f"on `hebb {cmd}` (have: {sorted(subs)})" + ) + + assert not failures, f"{doc_path.name} references unregistered commands:\n" + "\n".join(failures) + + +# ── Tests: generated config drift ─────────────────────────────────────── + + +class TestGeneratedConfigDrift: + """Ensure installer-generated hook commands resolve to real CLI commands.""" + + def test_codex_hooks_reference_real_commands(self) -> None: + from hebb.integrations.codex.install import hooks_config + + top_level = _registered_commands() + failures: list[str] = [] + + for event, handlers in hooks_config().items(): + for handler in handlers: + hooks_list = handler.get("hooks", []) + for h in hooks_list: + cmd_str = str(h.get("command", "")) + # Extract hebb subcommands from the command string + for m in re.finditer(r"hebb\s+([a-z][-a-z]*(?:\s+[a-z][-a-z]*)?)", cmd_str): + parts = m.group(1).strip().split() + cmd = parts[0] + if cmd not in top_level: + failures.append(f" hooks_config '{cmd_str}' — '{cmd}' not registered") + elif len(parts) > 1: + subs = _registered_subcommands(cmd) + if parts[1] not in subs: + failures.append( + f" hooks_config '{cmd_str}' — '{parts[1]}' not a subcommand of `hebb {cmd}`" + ) + + assert not failures, "Codex hooks reference unregistered commands:\n" + "\n".join(failures) + + +# ── Tests: entrypoint ─────────────────────────────────────────────────── + + +class TestEntrypoint: + """Verify the hebb-mcp console script points to a real module.""" + + def test_hebb_mcp_entrypoint_importable(self) -> None: + """The module referenced by the ``hebb-mcp`` console script must + be importable.""" + module = importlib.import_module("hebb.mcp.server") + assert hasattr(module, "main"), "hebb.mcp.server must expose a main() function"