From 350734c9f5f3fe73a9ebab6eb75c957354d525ee Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Tue, 28 Apr 2026 13:23:59 -0500 Subject: [PATCH] Add `me claude install` for MCP-only Claude Code setup Adds an MCP-only install path for Claude Code, matching the existing `me opencode/codex/gemini install` pattern. The full Claude Code plugin flow (hooks + slash commands + MCP via plugin marketplace) is unchanged. Also exposes `-s, --scope` on `me claude install` (local|user|project, default user) and `me gemini install` (user|project, default user) by plumbing scope through the shared MCP install pipeline. --- README.md | 3 +- docs/agents.txt | 5 +- docs/cli/me-claude.md | 33 ++++++- docs/cli/me-gemini.md | 1 + packages/cli/commands/claude.ts | 73 +++++++++++++-- packages/cli/commands/gemini.ts | 40 +++++++-- packages/cli/mcp/agent-install.ts | 7 +- packages/cli/mcp/install.test.ts | 142 +++++++++++++++++++++++++++++- packages/cli/mcp/install.ts | 67 ++++++++++---- 9 files changed, 331 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index ca40e65..5abdcbb 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ me memory search "how does authentication work" me opencode install me codex install me gemini install +me claude install # MCP-only -# Claude Code uses the Memory Engine plugin +# Or, for the full Claude Code plugin (hooks + slash commands + MCP): claude plugin marketplace add timescale/memory-engine claude plugin install memory-engine@memory-engine ``` diff --git a/docs/agents.txt b/docs/agents.txt index ca3aecb..f95e6e0 100644 --- a/docs/agents.txt +++ b/docs/agents.txt @@ -14,8 +14,9 @@ mcp: opencode: me opencode install codex_cli: me codex install gemini_cli: me gemini install - claude_code: claude plugin marketplace add timescale/memory-engine && claude plugin install memory-engine@memory-engine - description: Memory engine ships as an MCP server. OpenCode, Codex CLI, and Gemini CLI use agent-specific install commands. Claude Code uses the Memory Engine plugin. + claude_code: me claude install + claude_code_plugin: claude plugin marketplace add timescale/memory-engine && claude plugin install memory-engine@memory-engine + description: Memory engine ships as an MCP server. OpenCode, Codex CLI, Gemini CLI, and Claude Code all support MCP-only install via `me install`. Claude Code additionally supports a full plugin (hooks + slash commands + MCP) via Claude Code's plugin marketplace. compatible_clients: - Claude Code - Codex CLI diff --git a/docs/cli/me-claude.md b/docs/cli/me-claude.md index b7c8da3..cbd5b2a 100644 --- a/docs/cli/me-claude.md +++ b/docs/cli/me-claude.md @@ -4,11 +4,40 @@ Claude Code integration commands. ## Commands +- [me claude install](#me-claude-install) -- register `me` as an MCP server with Claude Code (MCP-only) - [me claude hook](#me-claude-hook) -- invoked by the Claude Code plugin to capture events as memories - [me claude import](#me-claude-import) -- import Claude Code sessions from `~/.claude/projects` --- +## me claude install + +Register `me` as an MCP server with Claude Code. + +This is the **MCP-only** install path: it adds the `me` tools to Claude Code without installing the full Memory Engine plugin. If you want hooks (auto-capture of Claude Code events) and slash commands, install the plugin instead -- see [me claude hook](#me-claude-hook). + +``` +me claude install [options] +``` + +| Option | Description | +|--------|-------------| +| `--api-key ` | API key to embed in the MCP config. | +| `--server ` | Server URL to embed in the MCP config. | +| `-s, --scope ` | Claude Code config scope: `local`, `user`, or `project`. Default: `user`. | + +If no `--api-key` or `--server` is provided, values are resolved from `~/.config/me/credentials.yaml` (set by `me login` and `me engine use`). + +The `--scope` flag mirrors `claude mcp add --scope`: + +- `local` -- registration scoped to the current project on this machine only. +- `user` -- registration available to all projects for your user (default). +- `project` -- registration committed to the current project (e.g. checked into `.claude/`). + +For manual MCP client configuration, see [MCP Integration](../mcp-integration.md). + +--- + ## me claude hook Invoked by the Claude Code plugin. Reads the event JSON from stdin, resolves config from `CLAUDE_PLUGIN_OPTION_*` env vars, and captures the event as a memory. @@ -21,7 +50,7 @@ me claude hook --event |--------|-------------| | `--event ` | Hook event name (required). | -This command is not run directly -- the Claude Code plugin calls it. The plugin is installed via Claude Code's native flow: +This command is not run directly -- the Claude Code plugin calls it. The plugin (which includes hooks, slash commands, and MCP) is installed via Claude Code's native flow: ```bash claude plugin marketplace add timescale/memory-engine @@ -30,6 +59,8 @@ claude plugin install memory-engine@memory-engine [--scope user|project|local] /plugin # select memory-engine, Configure, fill api_key/server/tree_prefix ``` +If you only want the MCP tools (no hooks, no slash commands), use [me claude install](#me-claude-install) instead. + Best-effort: logs failures to stderr but always exits 0 so that a hook failure never blocks a Claude Code session. --- diff --git a/docs/cli/me-gemini.md b/docs/cli/me-gemini.md index a3faf6e..bd6a267 100644 --- a/docs/cli/me-gemini.md +++ b/docs/cli/me-gemini.md @@ -20,6 +20,7 @@ me gemini install [options] |--------|-------------| | `--api-key ` | API key to embed in the MCP config. | | `--server ` | Server URL to embed in the MCP config. | +| `-s, --scope ` | Gemini CLI config scope: `user` or `project`. Default: `user`. | If no `--api-key` or `--server` is provided, values are resolved from `~/.config/me/credentials.yaml` (set by `me login` and `me engine use`). diff --git a/packages/cli/commands/claude.ts b/packages/cli/commands/claude.ts index 4772879..0bd41d0 100644 --- a/packages/cli/commands/claude.ts +++ b/packages/cli/commands/claude.ts @@ -1,18 +1,23 @@ /** * me claude — Claude Code integration commands. * - * Just one subcommand: `me claude hook --event `. The plugin itself - * is installed via Claude Code's native flow: + * Two integration paths: * - * claude plugin marketplace add timescale/memory-engine - * claude plugin install memory-engine@memory-engine [--scope user|project|local] - * # then, in a Claude Code session: - * /plugin # select memory-engine, Configure, fill api_key/server/tree_prefix + * 1. Full plugin (hooks + slash commands + MCP) via Claude Code's native + * plugin marketplace: * - * Claude Code delivers the configured values to our hook as - * CLAUDE_PLUGIN_OPTION_* env vars. + * claude plugin marketplace add timescale/memory-engine + * claude plugin install memory-engine@memory-engine [--scope user|project|local] + * # then, in a Claude Code session: + * /plugin # select memory-engine, Configure, fill api_key/server/tree_prefix + * + * Claude Code delivers the configured values to our hook (`me claude + * hook --event `) via CLAUDE_PLUGIN_OPTION_* env vars. + * + * 2. MCP-only via `me claude install`. Registers `me` as an MCP server + * with Claude Code (no hooks, no slash commands — just the tools). */ -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { captureHookEvent, HOOK_EVENT_NAMES, @@ -21,8 +26,57 @@ import { resolveHookConfigFromEnv, } from "../claude/capture.ts"; import { claudeImporter } from "../importers/claude.ts"; +import { + type AgentInstallOptions, + runAgentMcpInstall, +} from "../mcp/agent-install.ts"; import { buildAgentImportSubcommand } from "./import.ts"; +const CLAUDE_SCOPES = ["local", "user", "project"] as const; +type ClaudeScope = (typeof CLAUDE_SCOPES)[number]; + +function parseClaudeScope(value: string): ClaudeScope { + if (!CLAUDE_SCOPES.includes(value as ClaudeScope)) { + throw new InvalidArgumentError( + `must be one of: ${CLAUDE_SCOPES.join(", ")}`, + ); + } + return value as ClaudeScope; +} + +/** + * me claude install — register me as an MCP server with Claude Code. + * + * MCP-only: leaves the full Claude Code plugin install flow alone. Use this + * if you want the `me` MCP tools available in Claude Code but don't want the + * plugin's hooks or slash commands. + */ +function createClaudeInstallCommand(): Command { + return new Command("install") + .description("register me as an MCP server with Claude Code") + .option("--api-key ", "API key to embed in MCP config") + .option("--server ", "server URL to embed in MCP config") + .option( + "-s, --scope ", + `Claude Code config scope (${CLAUDE_SCOPES.join(", ")})`, + parseClaudeScope, + "user", + ) + .action( + async ( + opts: AgentInstallOptions & { scope: ClaudeScope }, + cmd: Command, + ) => { + const globalOpts = cmd.optsWithGlobals(); + await runAgentMcpInstall("claude", { + apiKey: opts.apiKey, + server: globalOpts.server ?? opts.server, + scope: opts.scope, + }); + }, + ); +} + /** * me claude hook — invoked by the Claude Code plugin to capture events as * memories. @@ -101,6 +155,7 @@ function createClaudeHookCommand(): Command { export function createClaudeCommand(): Command { const claude = new Command("claude").description("Claude Code integration"); + claude.addCommand(createClaudeInstallCommand()); claude.addCommand(createClaudeHookCommand()); claude.addCommand( buildAgentImportSubcommand( diff --git a/packages/cli/commands/gemini.ts b/packages/cli/commands/gemini.ts index 8f7e32c..6a51d78 100644 --- a/packages/cli/commands/gemini.ts +++ b/packages/cli/commands/gemini.ts @@ -3,24 +3,48 @@ * * - me gemini install: register me as an MCP server with Gemini CLI */ -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { type AgentInstallOptions, runAgentMcpInstall, } from "../mcp/agent-install.ts"; +const GEMINI_SCOPES = ["user", "project"] as const; +type GeminiScope = (typeof GEMINI_SCOPES)[number]; + +function parseGeminiScope(value: string): GeminiScope { + if (!GEMINI_SCOPES.includes(value as GeminiScope)) { + throw new InvalidArgumentError( + `must be one of: ${GEMINI_SCOPES.join(", ")}`, + ); + } + return value as GeminiScope; +} + function createGeminiInstallCommand(): Command { return new Command("install") .description("register me as an MCP server with Gemini CLI") .option("--api-key ", "API key to embed in MCP config") .option("--server ", "server URL to embed in MCP config") - .action(async (opts: AgentInstallOptions, cmd: Command) => { - const globalOpts = cmd.optsWithGlobals(); - await runAgentMcpInstall("gemini", { - apiKey: opts.apiKey, - server: globalOpts.server ?? opts.server, - }); - }); + .option( + "-s, --scope ", + `Gemini CLI config scope (${GEMINI_SCOPES.join(", ")})`, + parseGeminiScope, + "user", + ) + .action( + async ( + opts: AgentInstallOptions & { scope: GeminiScope }, + cmd: Command, + ) => { + const globalOpts = cmd.optsWithGlobals(); + await runAgentMcpInstall("gemini", { + apiKey: opts.apiKey, + server: globalOpts.server ?? opts.server, + scope: opts.scope, + }); + }, + ); } export function createGeminiCommand(): Command { diff --git a/packages/cli/mcp/agent-install.ts b/packages/cli/mcp/agent-install.ts index f188efd..fc1e62d 100644 --- a/packages/cli/mcp/agent-install.ts +++ b/packages/cli/mcp/agent-install.ts @@ -11,6 +11,11 @@ import { buildMeCommand, installMcpServer, MCP_TOOLS } from "./install.ts"; export interface AgentInstallOptions { apiKey?: string; server?: string; + /** + * Configuration scope for tools that support it (Claude Code, Gemini CLI). + * Ignored by tools without a scope concept (Codex, OpenCode). + */ + scope?: string; } /** @@ -63,7 +68,7 @@ export async function runAgentMcpInstall( const spin = clack.spinner(); spin.start(`Registering with ${tool.name}...`); - const result = await installMcpServer(tool, meCmd); + const result = await installMcpServer(tool, meCmd, { scope: opts.scope }); if (result.success) { spin.stop(result.message); diff --git a/packages/cli/mcp/install.test.ts b/packages/cli/mcp/install.test.ts index 197125d..2e59640 100644 --- a/packages/cli/mcp/install.test.ts +++ b/packages/cli/mcp/install.test.ts @@ -2,7 +2,7 @@ * Unit tests for MCP install helpers. */ import { describe, expect, test } from "bun:test"; -import { buildMeCommand, buildOpenCodeConfig } from "./install.ts"; +import { buildMeCommand, buildOpenCodeConfig, MCP_TOOLS } from "./install.ts"; describe("buildMeCommand", () => { test("uses bare 'me' command on PATH", () => { @@ -96,3 +96,143 @@ describe("buildOpenCodeConfig", () => { }); }); }); + +// Helpers to fish a tool out of the registry without leaking internal types. +function findCliTool(bin: string) { + const tool = MCP_TOOLS.find((t) => t.bin === bin); + if (!tool || tool.method !== "cli") { + throw new Error(`expected CLI tool with bin '${bin}'`); + } + return tool; +} + +describe("Claude Code scope handling", () => { + const meCmd = ["me", "mcp", "--api-key", "k", "--server", "https://x"]; + const claude = findCliTool("claude"); + + test("addCmd defaults to --scope user", () => { + expect(claude.addCmd(meCmd, {})).toEqual([ + "claude", + "mcp", + "add", + "--scope", + "user", + "me", + "--", + ...meCmd, + ]); + }); + + test("addCmd honors explicit project scope", () => { + expect(claude.addCmd(meCmd, { scope: "project" })).toEqual([ + "claude", + "mcp", + "add", + "--scope", + "project", + "me", + "--", + ...meCmd, + ]); + }); + + test("addCmd honors explicit local scope", () => { + expect(claude.addCmd(meCmd, { scope: "local" })).toEqual([ + "claude", + "mcp", + "add", + "--scope", + "local", + "me", + "--", + ...meCmd, + ]); + }); + + test("removeCmd defaults to --scope user", () => { + expect(claude.removeCmd({})).toEqual([ + "claude", + "mcp", + "remove", + "--scope", + "user", + "me", + ]); + }); + + test("removeCmd honors explicit project scope", () => { + expect(claude.removeCmd({ scope: "project" })).toEqual([ + "claude", + "mcp", + "remove", + "--scope", + "project", + "me", + ]); + }); +}); + +describe("Gemini CLI scope handling", () => { + const meCmd = ["me", "mcp", "--api-key", "k", "--server", "https://x"]; + const gemini = findCliTool("gemini"); + + test("addCmd defaults to --scope user", () => { + expect(gemini.addCmd(meCmd, {})).toEqual([ + "gemini", + "mcp", + "add", + "--scope", + "user", + "me", + ...meCmd, + ]); + }); + + test("addCmd honors explicit project scope", () => { + expect(gemini.addCmd(meCmd, { scope: "project" })).toEqual([ + "gemini", + "mcp", + "add", + "--scope", + "project", + "me", + ...meCmd, + ]); + }); + + test("removeCmd defaults to --scope user", () => { + expect(gemini.removeCmd({})).toEqual([ + "gemini", + "mcp", + "remove", + "--scope", + "user", + "me", + ]); + }); +}); + +describe("Codex CLI (no scope)", () => { + const meCmd = ["me", "mcp", "--api-key", "k", "--server", "https://x"]; + const codex = findCliTool("codex"); + + test("addCmd ignores scope opt", () => { + expect(codex.addCmd(meCmd, { scope: "project" })).toEqual([ + "codex", + "mcp", + "add", + "me", + "--", + ...meCmd, + ]); + }); + + test("removeCmd ignores scope opt", () => { + expect(codex.removeCmd({ scope: "project" })).toEqual([ + "codex", + "mcp", + "remove", + "me", + ]); + }); +}); diff --git a/packages/cli/mcp/install.ts b/packages/cli/mcp/install.ts index b83f7a3..a2a6abb 100644 --- a/packages/cli/mcp/install.ts +++ b/packages/cli/mcp/install.ts @@ -13,6 +13,16 @@ import { dirname, join } from "node:path"; // Tool Registry // ============================================================================= +/** + * Per-install options shared across tool installers. + * + * - `scope`: configuration scope for tools that support it (Claude Code, + * Gemini CLI). Ignored by tools without a scope concept (Codex, OpenCode). + */ +export interface McpInstallOpts { + scope?: string; +} + interface McpToolBase { name: string; bin: string; @@ -20,13 +30,13 @@ interface McpToolBase { interface McpToolCli extends McpToolBase { method: "cli"; - addCmd: (meCmd: string[]) => string[]; - removeCmd: string[]; + addCmd: (meCmd: string[], opts: McpInstallOpts) => string[]; + removeCmd: (opts: McpInstallOpts) => string[]; } interface McpToolJsonFile extends McpToolBase { method: "json-file"; - install: (meCmd: string[]) => Promise; + install: (meCmd: string[], opts: McpInstallOpts) => Promise; } type McpTool = McpToolCli | McpToolJsonFile; @@ -36,39 +46,53 @@ export const MCP_TOOLS: McpTool[] = [ name: "Claude Code", bin: "claude", method: "cli", - addCmd: (meCmd) => [ + addCmd: (meCmd, { scope = "user" }) => [ "claude", "mcp", "add", "--scope", - "user", + scope, "me", "--", ...meCmd, ], - removeCmd: ["claude", "mcp", "remove", "--scope", "user", "me"], + removeCmd: ({ scope = "user" }) => [ + "claude", + "mcp", + "remove", + "--scope", + scope, + "me", + ], }, { name: "Gemini CLI", bin: "gemini", method: "cli", - addCmd: (meCmd) => [ + addCmd: (meCmd, { scope = "user" }) => [ "gemini", "mcp", "add", "--scope", - "user", + scope, "me", ...meCmd, ], - removeCmd: ["gemini", "mcp", "remove", "--scope", "user", "me"], + removeCmd: ({ scope = "user" }) => [ + "gemini", + "mcp", + "remove", + "--scope", + scope, + "me", + ], }, { name: "Codex CLI", bin: "codex", method: "cli", addCmd: (meCmd) => ["codex", "mcp", "add", "me", "--", ...meCmd], - removeCmd: ["codex", "mcp", "remove", "me"], + removeCmd: () => ["codex", "mcp", "remove", "me"], }, { name: "OpenCode", @@ -114,8 +138,9 @@ export interface InstallResult { async function runAddCmd( tool: McpToolCli, meCmd: string[], + opts: McpInstallOpts, ): Promise<{ exitCode: number; stderr: string }> { - const cmd = tool.addCmd(meCmd); + const cmd = tool.addCmd(meCmd, opts); const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); const exitCode = await proc.exited; const stderr = exitCode === 0 ? "" : await new Response(proc.stderr).text(); @@ -131,8 +156,9 @@ async function runAddCmd( async function installViaCli( tool: McpToolCli, meCmd: string[], + opts: McpInstallOpts, ): Promise { - let { exitCode, stderr } = await runAddCmd(tool, meCmd); + let { exitCode, stderr } = await runAddCmd(tool, meCmd, opts); if (exitCode === 0) { return { success: true, message: `Registered with ${tool.name}` }; @@ -140,10 +166,13 @@ async function installViaCli( // Prior registration exists — remove it and re-add with current credentials if (stderr.includes("already exists")) { - const rm = Bun.spawn(tool.removeCmd, { stdout: "pipe", stderr: "pipe" }); + const rm = Bun.spawn(tool.removeCmd(opts), { + stdout: "pipe", + stderr: "pipe", + }); await rm.exited; - ({ exitCode, stderr } = await runAddCmd(tool, meCmd)); + ({ exitCode, stderr } = await runAddCmd(tool, meCmd, opts)); if (exitCode === 0) { return { @@ -165,11 +194,12 @@ async function installViaCli( export async function installMcpServer( tool: McpTool, meCmd: string[], + opts: McpInstallOpts = {}, ): Promise { if (tool.method === "cli") { - return installViaCli(tool, meCmd); + return installViaCli(tool, meCmd, opts); } - return tool.install(meCmd); + return tool.install(meCmd, opts); } // ============================================================================= @@ -217,7 +247,10 @@ export function buildOpenCodeConfig( * Creates the config file and its parent directory if missing. * Preserves any other keys in the existing config. */ -async function installOpenCode(meCmd: string[]): Promise { +async function installOpenCode( + meCmd: string[], + _opts: McpInstallOpts, +): Promise { const configPath = openCodeConfigPath(); let existing: Record = {};