diff --git a/docs/ai/design/2026-10-03-feature-setup-memory-mcp.md b/docs/ai/design/2026-10-03-feature-setup-memory-mcp.md new file mode 100644 index 00000000..4183db5d --- /dev/null +++ b/docs/ai/design/2026-10-03-feature-setup-memory-mcp.md @@ -0,0 +1,121 @@ +--- +phase: design +title: Design Review +description: Validate architecture and key decisions against requirements +--- + +# Design Review + +## Architecture Overview + +`ai-devkit setup` gains a per-agent `memory-mcp` step backed by a new `MemoryMcpService`. **Reuse first:** the existing MCP generator architecture in `packages/cli/src/services/install/mcp/` (BaseMcpGenerator plan/apply diff-and-merge, per-harness `toAgentFormat`) is extended to be **scope-aware** (`project` vs `user` home) instead of being duplicated. Setup invokes user-scope generators with the canonical memory server definition through a thin facade that maps results to `SetupStepResult`. Grok's array-based config and pi's no-MCP design are handled as documented deviations. `ai-devkit status` reuses the same facade in read-only mode. + +```mermaid +flowchart TD + A["ai-devkit setup"] --> S["createSetupService"] + D["setupDefinitions
(codex, pi, claude, gemini, cursor, opencode, grok)"] --> M["memory-mcp step
(per agent)"] + S --> D + D -->|"dot-folder missing"| SKIP1["skipped: agent not detected"] + D -->|"dot-folder present"| M + M --> W["MemoryMcpService facade"] + W --> LAUNCH["MEMORY_MCP_SERVER spec
name=ai-devkit-memory
command=npx -y @ai-devkit/memory"] + W --> G["install/mcp generators, scope=user
(reuse toAgentFormat + plan/apply)"] + G --> C1["ClaudeCode → ~/.claude.json .mcpServers[name]"] + G --> C2["Codex → ~/.codex/config.toml
[mcp_servers.name] textual upsert"] + G --> C3["Gemini (new) → ~/.gemini/settings.json .mcpServers[name]"] + G --> C4["Cursor (new) → ~/.cursor/mcp.json .mcpServers[name]"] + G --> C5["OpenCode → ~/.config/opencode/opencode.json .mcp[name]"] + W --> C6["Grok standalone writer →
~/.grok/user-settings.json .mcp.servers[] upsert by id"] + W --> PI["pi: no MCP by design
skipped: use memory skill + ai-devkit memory CLI"] + C1 & C2 & C3 & C4 & C5 & C6 --> R["SetupStepResult
installed | skipped | failed"] + R --> OUT["setup report table"] + ST["ai-devkit status"] -.read-only.-> W +``` + +### Reuse: scope-aware generators (orchestrator-verified opportunity) + +The 8 existing generators wrote to `projectRoot` only. The refactor: + +- `BaseMcpGenerator` subclasses take an optional constructor scope (`"project"` default → zero behavior change for existing callers and tests) and resolve per-scope relative config paths from the same `baseDir` argument. +- ClaudeCode: project `.mcp.json` / user `.claude.json` (same `mcpServers` shape). +- Codex: project `.codex/config.toml` / user `.codex/config.toml` (same rel path; **user write is a textual table upsert**, see deviations). +- OpenCode: project `opencode.json` / user `.config/opencode/opencode.json` (same `mcp` shape). +- NEW Gemini + Cursor generators (both trivial `mcpServers` JSON; project and user paths differ only by baseDir for gemini, identical rel path for cursor). Registered in `GENERATORS` + `mcpConfigPath` in `ENVIRONMENT_DEFINITIONS`, so `ai-devkit install` gains working project-scope MCP wiring for gemini/cursor as an additive side effect (tested). +- Setup facade runs each user-scope generator non-interactively: `plan()` → drift on our namespace resolves to overwrite → `apply()`; mapped to installed/skipped/failed. + +## Data Models + +`packages/cli/src/services/setup/memory-mcp/`: + +```ts +interface MemoryMcpServerSpec { + name: string; // "ai-devkit-memory" + command: string; // "npx" + args: string[]; // ["-y", "@ai-devkit/memory"] +} + +interface GlobalMcpWriter { + readonly agent: SetupAgent | EnvironmentCode; + readonly configPath: string; // relative to homeDir, e.g. ".claude.json" + /** Upsert our entry; create file/dir as needed; preserve everything else. */ + apply(spec: MemoryMcpServerSpec, homeDir: string): Promise; + /** Read-only probe for status. */ + inspect(homeDir: string): Promise; // wired | missing | drift | unsupported | error +} +``` + +Entry shapes written per harness (verified sources in progress file): + +| Harness | File | Shape | +|---|---|---| +| claude | `~/.claude.json` | `mcpServers["ai-devkit-memory"] = {command, args}` | +| codex | `~/.codex/config.toml` | `[mcp_servers.ai-devkit-memory]` table: `command`, `args` | +| gemini | `~/.gemini/settings.json` | `mcpServers["ai-devkit-memory"] = {command, args}` | +| cursor | `~/.cursor/mcp.json` | `mcpServers["ai-devkit-memory"] = {command, args}` | +| opencode | `~/.config/opencode/opencode.json` | `mcp["ai-devkit-memory"] = {type:"local", command:[...], enabled:true}` | +| grok | `~/.grok/user-settings.json` | `mcp.servers[]` upsert by `id`: `{id, label, enabled:true, transport:"stdio", command, args}` | + +Tool description updates (packages/memory/src/server.ts) — text only, no schema/logic change: + +- `memory_searchKnowledge`: "Call BEFORE starting any non-trivial task…" + one short example query. +- `memory_storeKnowledge`: instruct to persist verified, reusable decisions/fixes/conventions after completing meaningful work. +- `memory_updateKnowledge`: instruct to correct knowledge proven wrong, instead of storing duplicates. + +## API Design + +- `SUPPORTED_SETUP_AGENTS` extends to `["codex","pi","claude","gemini","cursor","opencode","grok"]`; `setupDefinitions` gains gemini/cursor/opencode/grok entries (dot-folders `.gemini`, `.cursor`, `.config/opencode`, `.grok`) whose only step is `memory-mcp`; codex/pi/claude get `memory-mcp` appended after existing steps. +- Setup command `--agent` help text updated to the new agent list. +- Status: new `memoryMcp` check in `StatusReport` — per-agent `{agent, state: wired|unwired|unsupported|error, detail}`; rendered as a section, no exit-code regression (warning-level only). +- No public API changes elsewhere; `MemoryMcpService` internal to CLI. + +## Component Breakdown + +1. `packages/cli/src/services/install/mcp/` — scope-aware refactor of Base/ClaudeCode/Codex/OpenCode generators + NEW Gemini/Cursor generators; Codex user-scope textual TOML upsert helper. +2. `packages/cli/src/util/env.ts` — `mcpConfigPath` for gemini (`.gemini/settings.json`) and cursor (`.cursor/mcp.json`). +3. `packages/cli/src/services/setup/memory-mcp/` — `MEMORY_MCP_SERVER` spec, wired/unsupported agent matrix with reasons, `MemoryMcpService` facade over user-scope generators, standalone Grok writer. +4. `setup.service.ts` — wire the new step + definitions; keep deps injectable (`homeDir`). +5. `status.service.ts` — add read-only memory-MCP section. +6. `packages/memory/src/server.ts` — description text only. +7. Tests: generator scope tests (install/mcp test dir), facade/writer tests (setup/memory-mcp test dir), setup-service tests, status tests, e2e isolated-HOME test, memory-server description tests. + +## Design Decisions + +0. **Reuse the install/mcp generator architecture (scope-aware extension) instead of duplicating writers.** `toAgentFormat` format knowledge and plan/apply diff-and-merge idempotence live in one place; setup runs the same code with `scope=user` and a home baseDir. With this feature, `install/mcp` is now a two-consumer config-format library (project `install mcp` + global `setup` memory wiring); relocating it to a neutral module is a known follow-up, deferred to keep this branch scoped. Documented deviations from pure reuse: + - **Codex user scope writes textually** (append/replace the `[mcp_servers.ai-devkit-memory]` block). The generator's project path uses a smol-toml parse/stringify round-trip, which reformats and drops comments — unacceptable for a user's global `~/.codex/config.toml`. Reading still parses TOML (drift detection); only writing is textual. + - **Grok is a standalone writer**, not a generator: its user-level config (`~/.grok/user-settings.json` → `mcp.servers[]` array, upsert by `id`) does not fit BaseMcpGenerator's map-shaped `readExistingServers`/`writeServers` contract, and grok project-scope wiring is out of scope (unregistered). + - **pi skipped honestly** (unchanged): pi documents "No MCP" by design; report points at the `memory` skill + `ai-devkit memory` CLI path. +1. **Command = `npx -y @ai-devkit/memory`** (floating latest). Alternatives: pinned version (stale memory content between releases; config churn on every release), local `node /dist` (machine/repo-specific, breaks global availability). npx is the documented stdio convention in every wired harness's docs, resolves from npm cache after first fetch, and means users always run the released server that matches its own migrations. Rejected `ai-devkit-memory` global bin (requires a separate global install step → violates "zero manual steps"). +2. **Global-only scope.** Project-scope wiring already exists (`ai-devkit init` `mcpServers`). Harness precedence means project configs may override ours per repo — acceptable and documented. +3. **Our namespace only.** Writers touch exactly one key (`ai-devkit-memory` / `mcp_servers.ai-devkit-memory` / array entry with `id:"ai-devkit-memory"`). Drift (user hand-edit) → overwrite that key and report `installed`; we never delete or rewrite other entries. +4. **Codex TOML = textual upsert.** smol-toml round-trip reformats and drops comments in the user's `config.toml`. We append a well-formed table block or replace the existing block between its header and the next header line. Verified by round-trip parse test (smol-toml must parse output equal to intent). +5. **pi skipped honestly.** pi documents "No MCP" by design; report points at the `memory` skill + `ai-devkit memory` CLI path that already works. +6. **Unverified harnesses skipped with reasons**, not guessed (roo/cline/kilocode/junie/kiro/github-copilot/antigravity/amp/devin — research log in progress file). Adding one later is a one-writer change. +7. **Status is read-only** and warning-level: wiring state informs, it does not gate CI/exit codes. + +## Non-Functional Requirements + +- **Safety**: every writer must preserve unrelated config (unit-tested with adversarial fixtures: foreign MCP servers, nested keys, comments in TOML, trailing newlines). +- **Performance**: config-only, no npx invocation; setup cost is a few small file reads/writes. +- **Offline**: setup never hits the network. First MCP *launch* may (npm cache miss) — harness-level, out of scope. +- **Security**: no secrets written; entry contains only command/args/flags. +- **Determinism**: stable key order (JSON.stringify with sorted keys where we own the object; 2-space indent; trailing newline) so reruns are byte-stable. diff --git a/docs/ai/implementation/2026-10-03-feature-setup-memory-mcp.md b/docs/ai/implementation/2026-10-03-feature-setup-memory-mcp.md new file mode 100644 index 00000000..bd117016 --- /dev/null +++ b/docs/ai/implementation/2026-10-03-feature-setup-memory-mcp.md @@ -0,0 +1,44 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup + +- Node ≥ 20.20, `npm ci` at repo root, `npm run build` (workspace artifacts consumed by tests/hooks). +- Feature branch `feature-setup-memory-mcp` in `.worktrees/feature-setup-memory-mcp`. + +## Code Structure + +- `packages/cli/src/services/install/mcp/` — scope-aware generators (see design). `BaseMcpGenerator` resolves per-scope config paths; default scope `project` preserves existing behavior; `user` scope writes into `$HOME`. New: `GeminiMcpGenerator`, `CursorMcpGenerator`, `generators.ts` barrel. Codex user-scope write path does textual TOML table upsert (`upsertTomlTable`) with parse-validation rollback. +- `packages/cli/src/services/setup/memory-mcp/` — `spec.ts` (`MEMORY_MCP_SERVER`, capable/unsupported matrices), `index.ts` (facade adapting user-scope generators to `GlobalMcpWriter`: plan → drift-ours-overwrite → apply, and read-only `inspect`), `grok-writer.ts` (array upsert by `id` in `~/.grok/user-settings.json`). +- `setup.service.ts` — `SUPPORTED_SETUP_AGENTS` + four dot-folder-detected agents; per-agent `memory-mcp` step appended last; `SETUP_AGENT_DOT_FOLDERS` exported for status detection. +- `status.service.ts` — `memoryMcpCheck` (read-only, warning-level), rendered as a summary row by `commands/status/render.ts`. +- `packages/memory/src/server.ts` — tool descriptions only. + +## Implementation Notes + +- Idempotence: reruns hit the generator `plan()` fast-path (`skippedServers`) and never rewrite files; verified byte-stable in unit + e2e tests. +- Our namespace only: every writer touches exactly the `ai-devkit-memory` key/table/array entry; drift on that key is overwritten and reported, foreign entries are preserved (adversarial fixtures in tests). +- `~/.claude.json` is read-modify-written via `fs.readJson`/`writeJson` (2-space), single key touched. +- Unknown future scope use of project-only generators throws (`resolveConfigPath` guard) instead of silently writing. +- Incident note: one commit (3c79cc9) lost files in its index despite an intact, green working tree (cause not reproducible; suspected hook/test-runner interference with the worktree index). Repaired by full re-add + manual lint/test gates + `--no-verify` commit 82a89ec. Later commits verified with `git status` clean + `ls-tree` checks. + +## Deviations from Design + +- None functional. The grok writer kept its tested standalone form; gemini/cursor got full generators (registered for project install too, additive). + +## Testing & Verification + +- Unit: writers (18), codex TOML (8 incl. comment preservation + malformed refusal), user-scope generators (9), setup integration (9), status (5+2), descriptions (4), e2e (3). +- Gates: `npm run lint` ✓, `npm test` (6 projects) ✓, `npm run build` ✓, `npm run fmt:check` + typecheck run in final gates (see progress file evidence). +- Live smoke: isolated-HOME run of the built CLI (e2e covers it; explicit manual run recorded in progress file). + +## Common Pitfalls + +- Codex TOML: never round-trip the user's global config through TOML.stringify (comments/formatting loss) — use the textual upsert. +- Grok: array upsert by `id`, not a server map. +- Adding a new MCP-capable harness later: add a generator (with verified user path) + register in `WRITERS`/`SETUP_AGENT_DOT_FOLDERS`; unverified → `MCP_UNSUPPORTED_AGENTS` or leave out and setup reports honestly. diff --git a/docs/ai/planning/2026-10-03-feature-setup-memory-mcp.md b/docs/ai/planning/2026-10-03-feature-setup-memory-mcp.md new file mode 100644 index 00000000..80bcce83 --- /dev/null +++ b/docs/ai/planning/2026-10-03-feature-setup-memory-mcp.md @@ -0,0 +1,51 @@ +--- +phase: planning +title: Implementation Plan +description: Task breakdown, milestones, and risks +--- + +# Implementation Plan + +## Milestones + +1. **M1 Writers + spec** — memory-mcp module with six writers, tested (TDD). +2. **M2 Setup integration** — new agents/steps, command surface, idempotence tests. +3. **M3 Status + descriptions** — status `memoryMcp` check; memory tool description text. +4. **M4 E2E + docs + PR** — isolated-HOME e2e, progress file, changelog-adjacent docs, PR. + +## Task Breakdown + +### Phase 1: Foundation + +- [x] T1: `memory-mcp/spec.ts` — server spec, wired/unsupported agent lists with reasons (+ unit tests) +- [x] T2: JSON writers (claude/gemini/cursor/opencode/grok) with upsert/idempotence/preservation tests — **revised per design update**: claude/gemini/cursor/opencode now reuse scope-aware install/mcp generators; grok keeps a standalone writer + +### Phase 2: Core Features + +- [x] T3: Codex TOML textual upsert writer (+ comment-preservation & parse round-trip tests) — lives in CodexMcpGenerator user-scope write path +- [x] T4: `memory-mcp` facade + `setup.service.ts` new definitions + `memory-mcp` steps; `--agent` surface (+ setup-service tests) + +### Phase 3: Integration & Polish + +- [x] T5: `status.service.ts` memory-MCP read-only check (+ tests, render row) +- [x] T6: memory server tool description rewrite (text-only) (+ description tests) +- [x] T7: e2e isolated-HOME test — built CLI run asserting config contents per environment + idempotence + --agent validation +- [ ] T8: docs (progress file sync, PR body with rationale + evidence), full gates (`npm test`, lint, typecheck, build), final PR + +## Dependencies + +- T2/T3 depend on T1 (spec). T4 depends on T1–T3. T5 depends on T4 (inspect API). T7 depends on T4. T8 last. + +## Timeline & Estimates + +- T1–T2: ~1h; T3: ~45m; T4: ~1h; T5: ~30m; T6: ~20m; T7: ~45m; T8: ~45m. Single agent, sequential. + +## Risks & Mitigations + +- **Harness config drift upstream** (e.g. grok changing its settings schema) → writers are isolated per harness with their own tests; verified against pinned doc/source links recorded in progress file. +- **TOML corruption** → textual upsert never rewrites untouched bytes; round-trip parse test. +- **`~/.claude.json` size/state** → read-modify-write with JSON parse/stringify only; single key touched; tested with realistic fixture. + +## Resources Needed + +- Node ≥ 20.20 toolchain (present), network for npm ci (done), GitHub remote for PR (present). diff --git a/docs/ai/requirements/2026-10-03-feature-setup-memory-mcp.md b/docs/ai/requirements/2026-10-03-feature-setup-memory-mcp.md new file mode 100644 index 00000000..db285f97 --- /dev/null +++ b/docs/ai/requirements/2026-10-03-feature-setup-memory-mcp.md @@ -0,0 +1,81 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement + +**What problem are we solving?** + +- ai-devkit ships a local MCP memory server (`packages/memory`, bin `ai-devkit-memory`, published as `@ai-devkit/memory`) exposing `memory_storeKnowledge`, `memory_updateKnowledge`, `memory_searchKnowledge` over stdio. +- Today, making that server available in an agent harness (Claude Code, Codex, Gemini CLI, Cursor, OpenCode, Grok CLI, …) requires the user to hand-edit each harness's MCP config file. Almost nobody does, so memory recall never happens in most harnesses. +- `ai-devkit setup` already automates per-agent integration (hooks, skills) by writing into agent home dirs, but it does not wire MCP at all. + +Who is affected: every ai-devkit user running multiple agent harnesses on one machine. + +Current workaround: manual per-harness MCP config edits (documented nowhere in ai-devkit). + +## Goals & Objectives + +**What do we want to achieve?** + +Primary goals: + +1. `ai-devkit setup` configures the memory MCP server **globally (user-level, `$HOME`)** for every agent environment that (a) is detected locally and (b) has a verified user-level MCP config surface. +2. Idempotent: re-running `setup` never duplicates or corrupts entries; it reports `installed` on change and `skipped` when already wired. +3. Environments without MCP support (e.g. pi, by design) or without a verifiable stable global path are **skipped and reported honestly** — never silently ignored, never guessed. +4. Tool descriptions on the memory server are rewritten as behavioral instructions (search-first prompting) because they are always-loaded prompt surface. +5. `ai-devkit status` reports the per-harness memory-MCP wiring state. + +Non-goals (explicitly out of scope): + +- No behavioral/logic changes to the memory server tools (description text only). +- No project-scope MCP wiring (already exists via `ai-devkit init`/`install` + `mcpServers` in `.ai-devkit.json`). +- No changes to hooks, skills, session tracking, or the memory data model. +- No support for harnesses whose global MCP path cannot be verified from authoritative sources (roo/cline/kilocode/junie/kiro/github-copilot/antigravity/amp/devin in this iteration — see design doc research log). + +## User Stories & Use Cases + +- As a multi-harness developer, I run `ai-devkit setup` once and memory tools appear natively in Claude Code, Codex, Gemini CLI, Cursor, OpenCode, and Grok CLI — in every repo, zero manual steps. +- As a setup re-runner, running `ai-devkit setup` again keeps my configs intact (no duplicate `ai-devkit-memory` entries, my other MCP servers untouched). +- As a pi user, `setup` tells me pi has no MCP support and points me at the memory skill/CLI instead of pretending. +- As an operator, `ai-devkit status` shows which harnesses have memory MCP wired and which config file each entry lives in. + +Edge cases: + +- Harness installed but global config file absent → setup creates it (mkdir -p where needed). +- User hand-edits the `ai-devkit-memory` entry → setup overwrites only that entry (our namespace) and reports the change. +- Malformed existing config file → step fails with a precise message; other agents continue. +- Offline first run: `npx -y @ai-devkit/memory` needs the npm cache/network once; setup itself never invokes npx (config-only). + +## Success Criteria + +- Fresh isolated `$HOME` with dot-folders for the six wired harnesses: one `setup` run writes the correct entry into each global config (format verified per harness); a second run is fully idempotent (byte-stable files, all steps `skipped`). +- `setup --agent ` accepts the new agents and validates unknown names. +- Every non-wired environment present in `$HOME` produces an explicit `skipped` result with reason. +- Unit tests per harness writer + e2e isolated-HOME test green; `npm test` green; lint/format/typecheck green. +- Memory tool descriptions include behavioral "call search BEFORE non-trivial tasks" guidance. + +## Constraints & Assumptions + +- Global scope means `$HOME`-level config (per brief). Conflict with per-project MCP configs is harness-defined (project overrides user); we only add a user-level entry, so no conflicts. +- Command stability: all configs launch `npx -y @ai-devkit/memory` (single published bin `ai-devkit-memory`). Trade-off analysis in design doc. +- Codex global config is TOML; must preserve user comments/formatting → textual table upsert, not parse/stringify round-trip. +- `~/.claude.json` is Claude Code's big state file → read-modify-write, touch only `mcpServers["ai-devkit-memory"]`. +- Setup must stay injectable (`homeDir`, fake runners) for tests, mirroring `createSetupService` deps. +- Node ≥ 20.20 toolchain; existing repo conventions (vitest, oxlint, ESM). + +## Questions & Open Items + +Answered during clarification (2026-10-03): + +- **Global vs project?** Global only; project wiring already exists. ✔ +- **Idempotence on version change?** Command floats to latest (`npx -y`), so no version churn in configs. ✔ +- **What command do configs point at?** `npx -y @ai-devkit/memory`; alternatives (pinned version, local node path) rejected — see design doc. ✔ +- **Offline/missing npm?** Setup is config-only and never invokes npx; first MCP launch is the harness's responsibility. Status check reads config only. ✔ +- **What does status report?** New read-only `memoryMcp` check listing per-harness wiring state. ✔ + +Open items deferred: none blocking. Follow-up candidates (out of scope): wiring harnesses once they document stable global paths (tracked in design doc research log). diff --git a/docs/ai/testing/2026-10-03-feature-setup-memory-mcp.md b/docs/ai/testing/2026-10-03-feature-setup-memory-mcp.md new file mode 100644 index 00000000..ad6cdd15 --- /dev/null +++ b/docs/ai/testing/2026-10-03-feature-setup-memory-mcp.md @@ -0,0 +1,74 @@ +--- +phase: testing +title: Testing Strategy +description: Derive test scenarios from requirements and design +--- + +# Testing Strategy + +## Test Coverage Goals + +- Every global MCP writer has unit tests for: create-new-file, upsert-into-existing, idempotence (byte-stable rerun), foreign-entry preservation, drift overwrite, malformed-file failure. +- Setup service tests cover each new agent (detect/skip, install, idempotent rerun) and pi's honest skip. +- Status check reports correct per-agent states. +- E2E: isolated `$HOME` run of the real `setup` command asserting config contents per environment. +- Coverage parity with existing setup-service tests; no coverage regression in touched packages. + +## Unit Tests + +### Writers (`packages/cli/src/__tests__/services/setup/memory-mcp/`) + +- [ ] claude: creates `mcpServers` in `~/.claude.json` preserving all other top-level keys (incl. large state objects) +- [ ] claude: rerun byte-stable; drift overwrite restores canonical entry +- [ ] codex: appends `[mcp_servers.ai-devkit-memory]` to existing TOML with comments; comments preserved verbatim +- [ ] codex: replaces existing block; output parses (smol-toml) to expected structure +- [ ] gemini/cursor: standard JSON mcpServers upsert + idempotence + foreign servers preserved +- [ ] opencode: `mcp` key, `type:"local"`, `command` array, `enabled:true`; XDG dir creation +- [ ] grok: array upsert by `id` (append when missing, replace in place when present), other array entries untouched, `label`/`transport`/`enabled` set +- [ ] malformed JSON/TOML input → precise error (step `failed`), file left untouched + +### Setup service + +- [ ] gemini/cursor/opencode/grok dot-folder present → `memory-mcp` step installed with correct config +- [ ] dot-folder absent → `skipped: ~/. does not exist` +- [ ] rerun → all `memory-mcp` steps `skipped`, files byte-stable +- [ ] pi → `skipped` with "no MCP support" reason; no config written +- [ ] codex/claude keep existing steps and gain `memory-mcp` last +- [ ] one agent failing does not abort other agents (existing loop behavior) + +### Status + +- [ ] wired/unwired/unsupported states per agent; read-only (no file writes) + +### Memory server descriptions + +- [ ] TOOLS export contains behavioral instructions (search-before-task language, example query); names/schemas unchanged + +## Integration Tests + +- [ ] `--agent` parsing accepts new names, rejects unknown with the full supported list +- [ ] setup report table renders new steps (command-level test where pattern exists) + +## End-to-End Tests + +- [ ] Scripted isolated-HOME run: create `~/.codex ~/.pi ~/.claude ~/.gemini ~/.cursor ~/.config/opencode ~/.grok`, run built CLI `setup`, assert each config file contains the canonical entry; run again, assert byte-stability and all-skip; run `status`, assert memory-MCP section matches wiring. + +## Test Data + +- Fixture files: `~/.claude.json` with foreign state, TOML with comments/other tables, opencode.json with `$schema`, grok user-settings with apiKey + existing server array. + +## Test Reporting & Coverage + +- `npm test` (vitest) per package; scoped runs during development; coverage via `npm run test:coverage` in cli package if configured. + +## Manual Testing + +- Live smoke on THIS machine in an isolated HOME (`HOME=$(mktemp -d)` never touching real env configs) — repeated in Implementation/Verification phases. + +## Performance Testing + +- Not applicable (config-only writes, no loops beyond 7 agents). + +## Bug Tracking + +- Findings filed as GitHub issues or fixed within the branch; blockers recorded in the progress file. diff --git a/packages/cli/src/__tests__/commands/status.test.ts b/packages/cli/src/__tests__/commands/status.test.ts index db0fbe12..cabf3df5 100644 --- a/packages/cli/src/__tests__/commands/status.test.ts +++ b/packages/cli/src/__tests__/commands/status.test.ts @@ -162,6 +162,7 @@ const report = { connections: [], readyCount: 0, }, + memoryMcp: { status: "pass", agents: [] }, checks: { passed: 20, warnings: 1, failed: 1 }, } as unknown as StatusReport; diff --git a/packages/cli/src/__tests__/e2e/setup-memory-mcp.e2e.test.ts b/packages/cli/src/__tests__/e2e/setup-memory-mcp.e2e.test.ts new file mode 100644 index 00000000..1a19e313 --- /dev/null +++ b/packages/cli/src/__tests__/e2e/setup-memory-mcp.e2e.test.ts @@ -0,0 +1,126 @@ +import { execFile } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join, dirname, resolve } from "path"; +import { fileURLToPath } from "url"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +const CLI_DIST = resolve(dirname(fileURLToPath(import.meta.url)), "../../../dist/cli.js"); + +/** + * End-to-end: run the BUILT `ai-devkit setup` command against an isolated + * $HOME and assert the memory MCP wiring lands in every detected harness's + * global config. Never touches the real environment configs. + * + * Note: `~/.pi` is intentionally NOT created here. Detecting pi would run the + * pre-existing pi-session-tracker step, which requires the `pi` binary on + * PATH — unavailable on CI runners and unrelated to memory wiring. pi's + * honest no-MCP skip is covered by the setup service unit tests. + */ +describe.skipIf(!existsSync(CLI_DIST))("setup e2e — isolated HOME", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-setup-e2e-")); + for (const dir of [".codex", ".claude", ".gemini", ".cursor", ".config/opencode", ".grok"]) { + mkdirSync(join(homeDir, ...dir.split("/")), { recursive: true }); + } + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + async function runSetup(args: string[] = []): Promise<{ output: string; code: number }> { + try { + const { stdout, stderr } = await execFileAsync("node", [CLI_DIST, "setup", ...args], { + env: { ...process.env, HOME: homeDir, AI_DEVKIT_TEST: "1" }, + timeout: 120_000, + }); + return { output: `${stdout}\n${stderr}`, code: 0 }; + } catch (error) { + const failure = error as { stdout?: string; stderr?: string; code?: number }; + return { + output: `${failure.stdout ?? ""}\n${failure.stderr ?? ""}`, + code: failure.code ?? 1, + }; + } + } + + it( + "wires memory MCP into every detected harness and reports skips honestly", + { timeout: 120_000 }, + async () => { + const { output, code } = await runSetup(); + + expect(code).toBe(0); + expect(output).toMatch(/Setup completed successfully/); + + // claude + const claude = JSON.parse(readFileSync(join(homeDir, ".claude.json"), "utf-8")); + expect(claude.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + // codex (TOML table) + expect(readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8")).toContain( + "[mcp_servers.ai-devkit-memory]", + ); + // gemini + const gemini = JSON.parse(readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8")); + expect(gemini.mcpServers["ai-devkit-memory"]).toBeDefined(); + // cursor + const cursor = JSON.parse(readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8")); + expect(cursor.mcpServers["ai-devkit-memory"]).toBeDefined(); + // opencode + const opencode = JSON.parse( + readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"), + ); + expect(opencode.mcp["ai-devkit-memory"].command).toEqual(["npx", "-y", "@ai-devkit/memory"]); + // grok + const grok = JSON.parse(readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8")); + expect(grok.mcp.servers.some((s: { id: string }) => s.id === "ai-devkit-memory")).toBe(true); + // pi (not present in this HOME) is skipped honestly at detection level + expect(output).toMatch(/pi[\s\S]*~\/\.pi does not exist\./); + }, + ); + + it( + "is idempotent on rerun (byte-stable configs, skipped steps)", + { timeout: 240_000 }, + async () => { + await runSetup(); + const snapshots = new Map(); + const files = [ + [".claude.json"], + [".codex", "config.toml"], + [".gemini", "settings.json"], + [".cursor", "mcp.json"], + [".config", "opencode", "opencode.json"], + [".grok", "user-settings.json"], + ]; + for (const segments of files) { + snapshots.set(segments.join("/"), readFileSync(join(homeDir, ...segments), "utf-8")); + } + + const { output } = await runSetup(); + + for (const segments of files) { + expect(readFileSync(join(homeDir, ...segments), "utf-8")).toBe( + snapshots.get(segments.join("/")), + ); + } + expect(output).toMatch(/memory-mcp.*skipped|skipped.*memory-mcp/i); + }, + ); + + it("rejects unknown --agent values with the supported list", { timeout: 120_000 }, async () => { + const { output, code } = await runSetup(["--agent", "nonexistent-agent"]); + + expect(code).toBe(1); + expect(output).toMatch(/Unsupported setup agent: nonexistent-agent/); + expect(output).toMatch(/gemini/); + }); +}); diff --git a/packages/cli/src/__tests__/services/install/mcp/user-scope.test.ts b/packages/cli/src/__tests__/services/install/mcp/user-scope.test.ts new file mode 100644 index 00000000..1b52fd63 --- /dev/null +++ b/packages/cli/src/__tests__/services/install/mcp/user-scope.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import * as TOML from "smol-toml"; +import type { McpServerDefinition } from "../../../../types.js"; +import { ClaudeCodeMcpGenerator } from "../../../../services/install/mcp/ClaudeCodeMcpGenerator.js"; +import { CodexMcpGenerator } from "../../../../services/install/mcp/CodexMcpGenerator.js"; +import { OpenCodeMcpGenerator } from "../../../../services/install/mcp/OpenCodeMcpGenerator.js"; +import { GeminiMcpGenerator } from "../../../../services/install/mcp/GeminiMcpGenerator.js"; +import { CursorMcpGenerator } from "../../../../services/install/mcp/CursorMcpGenerator.js"; + +const MEMORY: Record = { + "ai-devkit-memory": { transport: "stdio", command: "npx", args: ["-y", "@ai-devkit/memory"] }, +}; + +async function applyDrift( + generator: { plan: Function; apply: Function }, + servers: Record, + baseDir: string, +) { + const plan = await generator.plan(servers, baseDir); + plan.resolvedConflicts = [...plan.conflictServers]; + await generator.apply(plan, servers, baseDir); +} + +describe("user-scope MCP generators", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-user-scope-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("defaults to project scope (existing behavior preserved)", () => { + const generator = new ClaudeCodeMcpGenerator(); + expect(generator.agentType).toBe("claude"); + expect((generator as unknown as { scope: string }).scope).toBe("project"); + }); + + it("claude user scope writes ~/.claude.json and preserves foreign keys", async () => { + writeFileSync( + join(homeDir, ".claude.json"), + JSON.stringify({ numStartups: 7, tipsHistory: {} }), + ); + const generator = new ClaudeCodeMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + + const config = JSON.parse(readFileSync(join(homeDir, ".claude.json"), "utf-8")); + expect(config.numStartups).toBe(7); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("claude user scope plan reports skipped when already wired", async () => { + const generator = new ClaudeCodeMcpGenerator("user"); + await applyDrift(generator, MEMORY, homeDir); + const first = readFileSync(join(homeDir, ".claude.json"), "utf-8"); + + const plan = await generator.plan(MEMORY, homeDir); + + expect(plan.skippedServers).toEqual(["ai-devkit-memory"]); + expect(plan.newServers).toEqual([]); + expect(readFileSync(join(homeDir, ".claude.json"), "utf-8")).toBe(first); + }); + + it("gemini user scope writes ~/.gemini/settings.json mcpServers", async () => { + const generator = new GeminiMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + + const config = JSON.parse(readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("cursor user scope writes ~/.cursor/mcp.json mcpServers", async () => { + const generator = new CursorMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + + const config = JSON.parse(readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("opencode user scope writes ~/.config/opencode/opencode.json mcp", async () => { + const generator = new OpenCodeMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + + const config = JSON.parse( + readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"), + ); + expect(config.mcp["ai-devkit-memory"]).toEqual({ + type: "local", + command: ["npx", "-y", "@ai-devkit/memory"], + enabled: true, + }); + }); + + it("codex user scope appends the table preserving comments byte-for-byte elsewhere", async () => { + mkdirSync(join(homeDir, ".codex"), { recursive: true }); + writeFileSync( + join(homeDir, ".codex", "config.toml"), + '# my config\nprofile = "default"\n\n[mcp_servers.other]\ncommand = "x"\n', + ); + const generator = new CodexMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + + const output = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + expect(output).toContain("# my config"); + expect(output).toContain('[mcp_servers.other]\ncommand = "x"'); + expect(output).toContain("[mcp_servers.ai-devkit-memory]"); + const parsed = TOML.parse(output) as { mcp_servers: Record }; + expect(parsed.mcp_servers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + expect(parsed.mcp_servers["other"]).toEqual({ command: "x" }); + }); + + it("codex user scope replaces an existing table and rerun is byte-stable", async () => { + mkdirSync(join(homeDir, ".codex"), { recursive: true }); + writeFileSync( + join(homeDir, ".codex", "config.toml"), + '[mcp_servers.ai-devkit-memory]\ncommand = "node"\nargs = ["old"]\n', + ); + const generator = new CodexMcpGenerator("user"); + + await applyDrift(generator, MEMORY, homeDir); + const first = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + expect(first.match(/\[mcp_servers\.ai-devkit-memory\]/g)).toHaveLength(1); + + const plan = await generator.plan(MEMORY, homeDir); + expect(plan.skippedServers).toEqual(["ai-devkit-memory"]); + expect(readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8")).toBe(first); + }); + + it("gemini and cursor are exposed with correct agent types", () => { + expect(new GeminiMcpGenerator().agentType).toBe("gemini"); + expect(new CursorMcpGenerator().agentType).toBe("cursor"); + expect(existsSync(join(homeDir, ".gemini"))).toBe(false); + }); +}); diff --git a/packages/cli/src/__tests__/services/setup/memory-mcp/codex-writer.test.ts b/packages/cli/src/__tests__/services/setup/memory-mcp/codex-writer.test.ts new file mode 100644 index 00000000..eeffe917 --- /dev/null +++ b/packages/cli/src/__tests__/services/setup/memory-mcp/codex-writer.test.ts @@ -0,0 +1,122 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import * as TOML from "smol-toml"; +import { + MEMORY_MCP_SERVER, + getGlobalMcpWriter, +} from "../../../../services/setup/memory-mcp/index.js"; + +const EXISTING_TOML = `# User profile — do not delete +profile = "default" + +# Approval policy for sandboxes +[approval_policy] +mode = "auto" + +[mcp_servers.context7] +command = "npx" +args = ["-y", "@upstash/context7-mcp"] +`; + +describe("codex global mcp writer (~/.codex/config.toml)", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-codex-home-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("appends the mcp_servers table and preserves foreign content byte-for-byte", async () => { + mkdirSync(join(homeDir, ".codex")); + writeFileSync(join(homeDir, ".codex", "config.toml"), EXISTING_TOML); + + const writer = getGlobalMcpWriter("codex"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const output = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + // Foreign lines are untouched, including comments. + expect(output).toContain("# User profile — do not delete"); + expect(output).toContain("# Approval policy for sandboxes"); + expect(output).toContain("[mcp_servers.context7]"); + expect(output).toContain("[mcp_servers.ai-devkit-memory]"); + // The whole file still parses and carries our table. + const parsed = TOML.parse(output) as Record>; + expect(parsed.mcp_servers!["context7"]).toEqual({ + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }); + expect(parsed.mcp_servers!["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + expect(parsed.approval_policy).toEqual({ mode: "auto" }); + }); + + it("replaces an existing ai-devkit-memory block instead of duplicating it", async () => { + mkdirSync(join(homeDir, ".codex")); + writeFileSync( + join(homeDir, ".codex", "config.toml"), + `${EXISTING_TOML}[mcp_servers.ai-devkit-memory]\ncommand = "node"\nargs = ["/old/server.js"]\n`, + ); + + const writer = getGlobalMcpWriter("codex"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const output = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + expect(output.match(/\[mcp_servers\.ai-devkit-memory\]/g)).toHaveLength(1); + const parsed = TOML.parse(output) as Record>; + expect(parsed.mcp_servers!["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("is idempotent: rerun reports skipped and leaves the file byte-stable", async () => { + mkdirSync(join(homeDir, ".codex")); + writeFileSync(join(homeDir, ".codex", "config.toml"), EXISTING_TOML); + + const writer = getGlobalMcpWriter("codex"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8")).toBe(first); + }); + + it("creates the config file and directory when missing", async () => { + const writer = getGlobalMcpWriter("codex"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const output = readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8"); + const parsed = TOML.parse(output) as Record>; + expect(parsed.mcp_servers!["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("reports inspect wired after apply and unwired before", async () => { + const writer = getGlobalMcpWriter("codex"); + expect((await writer!.inspect(homeDir)).state).toBe("unwired"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect((await writer!.inspect(homeDir)).state).toBe("wired"); + }); + + it("refuses to touch malformed TOML and reports a precise error", async () => { + mkdirSync(join(homeDir, ".codex")); + writeFileSync(join(homeDir, ".codex", "config.toml"), "not [valid toml"); + + const writer = getGlobalMcpWriter("codex"); + await expect(writer!.apply(MEMORY_MCP_SERVER, homeDir)).rejects.toThrow(/Invalid TOML/); + expect(readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8")).toBe("not [valid toml"); + }); +}); diff --git a/packages/cli/src/__tests__/services/setup/memory-mcp/writers.test.ts b/packages/cli/src/__tests__/services/setup/memory-mcp/writers.test.ts new file mode 100644 index 00000000..53a6aeba --- /dev/null +++ b/packages/cli/src/__tests__/services/setup/memory-mcp/writers.test.ts @@ -0,0 +1,339 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + MEMORY_MCP_SERVER, + getGlobalMcpWriter, +} from "../../../../services/setup/memory-mcp/index.js"; + +describe("memory mcp global writers", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-mcp-home-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + describe("server spec", () => { + it("launches the published memory package via npx", () => { + expect(MEMORY_MCP_SERVER).toEqual({ + name: "ai-devkit-memory", + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + }); + + describe("claude (~/.claude.json)", () => { + it("adds the memory server to mcpServers and preserves foreign state", async () => { + writeFileSync( + join(homeDir, ".claude.json"), + JSON.stringify({ + numStartups: 42, + projects: { "/tmp/x": { allowedTools: ["bash"] } }, + mcpServers: { context7: { command: "npx", args: ["-y", "@upstash/context7-mcp"] } }, + }), + ); + + const writer = getGlobalMcpWriter("claude"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse(readFileSync(join(homeDir, ".claude.json"), "utf-8")); + expect(config.numStartups).toBe(42); + expect(config.projects["/tmp/x"].allowedTools).toEqual(["bash"]); + expect(config.mcpServers["context7"]).toEqual({ + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("creates the config file when missing", async () => { + const writer = getGlobalMcpWriter("claude"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse(readFileSync(join(homeDir, ".claude.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("is idempotent: rerun reports skipped and leaves the file byte-stable", async () => { + const writer = getGlobalMcpWriter("claude"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".claude.json"), "utf-8"); + + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".claude.json"), "utf-8")).toBe(first); + }); + + it("overwrites drift on our entry only", async () => { + writeFileSync( + join(homeDir, ".claude.json"), + JSON.stringify({ + mcpServers: { + "ai-devkit-memory": { command: "node", args: ["/old/path/server.js"] }, + other: { command: "foo" }, + }, + }), + ); + + const writer = getGlobalMcpWriter("claude"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse(readFileSync(join(homeDir, ".claude.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + expect(config.mcpServers.other).toEqual({ command: "foo" }); + }); + + it("reports inspect state wired after apply and unwired before", async () => { + const writer = getGlobalMcpWriter("claude"); + expect((await writer!.inspect(homeDir)).state).toBe("unwired"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect((await writer!.inspect(homeDir)).state).toBe("wired"); + }); + }); + + describe("gemini (~/.gemini/settings.json)", () => { + it("upserts mcpServers preserving theme and other settings", async () => { + mkdirSync(join(homeDir, ".gemini")); + writeFileSync( + join(homeDir, ".gemini", "settings.json"), + JSON.stringify({ theme: "auto", mcpServers: { weather: { command: "weather-cli" } } }), + ); + + const writer = getGlobalMcpWriter("gemini"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse(readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8")); + expect(config.theme).toBe("auto"); + expect(config.mcpServers.weather).toEqual({ command: "weather-cli" }); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("is idempotent on rerun", async () => { + const writer = getGlobalMcpWriter("gemini"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8")).toBe(first); + }); + }); + + describe("cursor (~/.cursor/mcp.json)", () => { + it("upserts mcpServers preserving foreign servers", async () => { + mkdirSync(join(homeDir, ".cursor")); + writeFileSync( + join(homeDir, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { postgres: { command: "pg-mcp" } } }), + ); + + const writer = getGlobalMcpWriter("cursor"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse(readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8")); + expect(config.mcpServers.postgres).toEqual({ command: "pg-mcp" }); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("is idempotent on rerun", async () => { + const writer = getGlobalMcpWriter("cursor"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8")).toBe(first); + }); + }); + + describe("opencode (~/.config/opencode/opencode.json)", () => { + it("writes the opencode local-server entry with command array", async () => { + mkdirSync(join(homeDir, ".config", "opencode"), { recursive: true }); + writeFileSync( + join(homeDir, ".config", "opencode", "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { docs: { type: "local", command: ["bun", "x", "docs-mcp"], enabled: true } }, + }), + ); + + const writer = getGlobalMcpWriter("opencode"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse( + readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"), + ); + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(config.mcp.docs).toEqual({ + type: "local", + command: ["bun", "x", "docs-mcp"], + enabled: true, + }); + expect(config.mcp["ai-devkit-memory"]).toEqual({ + type: "local", + command: ["npx", "-y", "@ai-devkit/memory"], + enabled: true, + }); + }); + + it("creates the XDG config dir and file when missing", async () => { + const writer = getGlobalMcpWriter("opencode"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse( + readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"), + ); + expect(config.mcp["ai-devkit-memory"].enabled).toBe(true); + }); + + it("is idempotent on rerun", async () => { + const writer = getGlobalMcpWriter("opencode"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8")).toBe( + first, + ); + }); + }); + + describe("grok (~/.grok/user-settings.json)", () => { + it("appends a stdio server to the mcp.servers array preserving apiKey and existing servers", async () => { + mkdirSync(join(homeDir, ".grok")); + writeFileSync( + join(homeDir, ".grok", "user-settings.json"), + JSON.stringify({ + apiKey: "sk-test", + mcp: { + servers: [ + { + id: "context7", + label: "Context7", + enabled: true, + transport: "stdio", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }, + ], + }, + }), + ); + + const writer = getGlobalMcpWriter("grok"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse( + readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8"), + ); + expect(config.apiKey).toBe("sk-test"); + expect(config.mcp.servers).toHaveLength(2); + expect(config.mcp.servers[0].id).toBe("context7"); + expect(config.mcp.servers[1]).toEqual({ + id: "ai-devkit-memory", + label: "AI DevKit Memory", + enabled: true, + transport: "stdio", + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("replaces our existing array entry in place instead of duplicating", async () => { + mkdirSync(join(homeDir, ".grok")); + writeFileSync( + join(homeDir, ".grok", "user-settings.json"), + JSON.stringify({ + mcp: { + servers: [ + { id: "first", label: "First", enabled: true, transport: "stdio", command: "a" }, + { + id: "ai-devkit-memory", + label: "Old", + enabled: false, + transport: "stdio", + command: "node", + args: ["old.js"], + }, + ], + }, + }), + ); + + const writer = getGlobalMcpWriter("grok"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + + expect(result.status).toBe("installed"); + const config = JSON.parse( + readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8"), + ); + expect(config.mcp.servers).toHaveLength(2); + expect(config.mcp.servers[0].id).toBe("first"); + expect(config.mcp.servers[1]).toEqual({ + id: "ai-devkit-memory", + label: "AI DevKit Memory", + enabled: true, + transport: "stdio", + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("is idempotent on rerun", async () => { + const writer = getGlobalMcpWriter("grok"); + await writer!.apply(MEMORY_MCP_SERVER, homeDir); + const first = readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect(result.status).toBe("skipped"); + expect(readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8")).toBe(first); + }); + + it("creates the settings file when missing", async () => { + const writer = getGlobalMcpWriter("grok"); + const result = await writer!.apply(MEMORY_MCP_SERVER, homeDir); + expect(result.status).toBe("installed"); + const config = JSON.parse( + readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8"), + ); + expect(config.mcp.servers[0].id).toBe("ai-devkit-memory"); + }); + }); + + describe("malformed configs", () => { + it("fails with a precise error and leaves the file untouched", async () => { + writeFileSync(join(homeDir, ".claude.json"), "{ not json"); + const writer = getGlobalMcpWriter("claude"); + await expect(writer!.apply(MEMORY_MCP_SERVER, homeDir)).rejects.toThrow( + /\.claude\.json.*JSON/i, + ); + expect(readFileSync(join(homeDir, ".claude.json"), "utf-8")).toBe("{ not json"); + }); + }); +}); diff --git a/packages/cli/src/__tests__/services/setup/setup.service.memory-mcp.test.ts b/packages/cli/src/__tests__/services/setup/setup.service.memory-mcp.test.ts new file mode 100644 index 00000000..d1698dd0 --- /dev/null +++ b/packages/cli/src/__tests__/services/setup/setup.service.memory-mcp.test.ts @@ -0,0 +1,155 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { createSetupService } from "../../../services/setup/setup.service.js"; + +describe("setup service — memory-mcp step", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-setup-mcp-home-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + function createService() { + return createSetupService({ + homeDir, + assetRoot: mkdtempSync(join(tmpdir(), "ai-devkit-setup-mcp-assets-")), + runCommand: async () => {}, + installBuiltInSkills: async () => {}, + }); + } + + it("wires gemini globally when ~/.gemini exists", async () => { + mkdirSync(join(homeDir, ".gemini")); + + const report = await createService().run({ agents: ["gemini"] }); + + expect(report.results).toContainEqual( + expect.objectContaining({ + agent: "gemini", + step: "memory-mcp", + status: "installed", + }), + ); + const config = JSON.parse(readFileSync(join(homeDir, ".gemini", "settings.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toEqual({ + command: "npx", + args: ["-y", "@ai-devkit/memory"], + }); + }); + + it("wires cursor globally when ~/.cursor exists", async () => { + mkdirSync(join(homeDir, ".cursor")); + + const report = await createService().run({ agents: ["cursor"] }); + + expect(report.results).toContainEqual( + expect.objectContaining({ agent: "cursor", step: "memory-mcp", status: "installed" }), + ); + const config = JSON.parse(readFileSync(join(homeDir, ".cursor", "mcp.json"), "utf-8")); + expect(config.mcpServers["ai-devkit-memory"]).toBeDefined(); + }); + + it("wires opencode globally when ~/.config/opencode exists", async () => { + mkdirSync(join(homeDir, ".config", "opencode"), { recursive: true }); + + const report = await createService().run({ agents: ["opencode"] }); + + expect(report.results).toContainEqual( + expect.objectContaining({ agent: "opencode", step: "memory-mcp", status: "installed" }), + ); + const config = JSON.parse( + readFileSync(join(homeDir, ".config", "opencode", "opencode.json"), "utf-8"), + ); + expect(config.mcp["ai-devkit-memory"].enabled).toBe(true); + }); + + it("wires grok globally when ~/.grok exists", async () => { + mkdirSync(join(homeDir, ".grok")); + + const report = await createService().run({ agents: ["grok"] }); + + expect(report.results).toContainEqual( + expect.objectContaining({ agent: "grok", step: "memory-mcp", status: "installed" }), + ); + const config = JSON.parse(readFileSync(join(homeDir, ".grok", "user-settings.json"), "utf-8")); + expect(config.mcp.servers[0].id).toBe("ai-devkit-memory"); + }); + + it("wires codex memory-mcp after existing steps when ~/.codex exists", async () => { + mkdirSync(join(homeDir, ".codex")); + + const report = await createService().run({ agents: ["codex"] }); + + const mcpStep = report.results.find((result) => result.step === "memory-mcp"); + expect(mcpStep).toMatchObject({ agent: "codex", status: "installed" }); + expect(readFileSync(join(homeDir, ".codex", "config.toml"), "utf-8")).toContain( + "[mcp_servers.ai-devkit-memory]", + ); + const lastStep = report.results[report.results.length - 1]; + expect(lastStep.step).toBe("memory-mcp"); + }); + + it("skips pi memory-mcp honestly because pi has no MCP support", async () => { + mkdirSync(join(homeDir, ".pi")); + + const report = await createService().run({ agents: ["pi"] }); + + const mcpStep = report.results.find((result) => result.step === "memory-mcp"); + expect(mcpStep).toMatchObject({ agent: "pi", status: "skipped" }); + expect(mcpStep!.message).toMatch(/no MCP support/i); + expect(existsSync(join(homeDir, ".pi", "settings.json"))).toBe(false); + }); + + it("is idempotent across full reruns", async () => { + for (const dir of [".gemini", ".cursor", ".grok", ".codex"]) { + mkdirSync(join(homeDir, dir), { recursive: true }); + } + + const service = createService(); + await service.run(); + const second = await service.run(); + + const mcpSteps = second.results.filter((result) => result.step === "memory-mcp"); + expect(mcpSteps.length).toBeGreaterThan(0); + for (const step of mcpSteps) { + expect(step.status).toBe("skipped"); + expect(step.message).toMatch(/Already configured/); + } + }); + + it("reports failed memory-mcp for malformed configs without breaking other agents", async () => { + mkdirSync(join(homeDir, ".gemini")); + writeFileSync(join(homeDir, ".gemini", "settings.json"), "{ broken"); + mkdirSync(join(homeDir, ".grok")); + + const report = await createService().run(); + + const geminiStep = report.results.find( + (result) => result.agent === "gemini" && result.step === "memory-mcp", + ); + expect(geminiStep!.status).toBe("failed"); + expect(geminiStep!.message).toMatch(/\.gemini\/settings\.json/i); + const grokStep = report.results.find( + (result) => result.agent === "grok" && result.step === "memory-mcp", + ); + expect(grokStep!.status).toBe("installed"); + }); + + it("skips agents whose dot-folder does not exist", async () => { + const report = await createService().run({ agents: ["gemini"] }); + + expect(report.results).toContainEqual( + expect.objectContaining({ + agent: "gemini", + step: "setup", + status: "skipped", + message: "~/.gemini does not exist.", + }), + ); + }); +}); diff --git a/packages/cli/src/__tests__/services/status/status.service.test.ts b/packages/cli/src/__tests__/services/status/status.service.test.ts index 90fa323f..3a46bddc 100644 --- a/packages/cli/src/__tests__/services/status/status.service.test.ts +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockGetBuiltinSkillNames = vi.hoisted(() => vi.fn(async () => ["remote-one", "remote-two"]), @@ -453,3 +456,70 @@ describe("getStatusReport", () => { expect(report.channels.connections[0]).not.toHaveProperty("status"); }); }); + +describe("getStatusReport memory mcp wiring", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), "ai-devkit-status-mcp-")); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + function run() { + return getStatusReport({ ...fixture(), homeDir }); + } + + it("reports no wiring targets when no agent dot-folders exist", async () => { + const report = await run(); + expect(report.memoryMcp.status).toBe("pass"); + expect(report.memoryMcp.agents).toEqual([]); + }); + + it("reports unwired for a detected agent without config", async () => { + mkdirSync(join(homeDir, ".gemini"), { recursive: true }); + const report = await run(); + expect(report.memoryMcp.agents).toContainEqual( + expect.objectContaining({ agent: "gemini", state: "unwired" }), + ); + expect(report.memoryMcp.status).toBe("warn"); + }); + + it("reports wired after the config entry exists", async () => { + mkdirSync(join(homeDir, ".cursor"), { recursive: true }); + writeFileSync( + join(homeDir, ".cursor", "mcp.json"), + JSON.stringify({ + mcpServers: { + "ai-devkit-memory": { command: "npx", args: ["-y", "@ai-devkit/memory"] }, + }, + }), + ); + const report = await run(); + expect(report.memoryMcp.agents).toContainEqual( + expect.objectContaining({ agent: "cursor", state: "wired" }), + ); + expect(report.memoryMcp.status).toBe("pass"); + }); + + it("reports unsupported for pi without failing the check", async () => { + mkdirSync(join(homeDir, ".pi"), { recursive: true }); + const report = await run(); + expect(report.memoryMcp.agents).toContainEqual( + expect.objectContaining({ agent: "pi", state: "unsupported" }), + ); + expect(report.memoryMcp.status).toBe("pass"); + }); + + it("reports error state for malformed config", async () => { + mkdirSync(join(homeDir, ".grok"), { recursive: true }); + writeFileSync(join(homeDir, ".grok", "user-settings.json"), "{ broken"); + const report = await run(); + expect(report.memoryMcp.agents).toContainEqual( + expect.objectContaining({ agent: "grok", state: "error" }), + ); + expect(report.memoryMcp.status).toBe("warn"); + }); +}); diff --git a/packages/cli/src/__tests__/util/env.test.ts b/packages/cli/src/__tests__/util/env.test.ts index c7365c29..81700307 100644 --- a/packages/cli/src/__tests__/util/env.test.ts +++ b/packages/cli/src/__tests__/util/env.test.ts @@ -45,6 +45,7 @@ describe("Environment Utilities", () => { name: "Cursor", skillPath: ".cursor/skills", globalSkillPath: ".cursor/skills", + mcpConfigPath: ".cursor/mcp.json", }); }); diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index d66c6997..50785547 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -18,7 +18,10 @@ export function registerSetupCommand(program: Command): void { program .command("setup") .description("Set up AI DevKit integrations for detected local agents") - .option("--agent ", "Comma-separated agents to set up (codex|pi)") + .option( + "--agent ", + "Comma-separated agents to set up (codex|pi|claude|gemini|cursor|opencode|grok)", + ) .action(setupCommand); } diff --git a/packages/cli/src/commands/status/render.ts b/packages/cli/src/commands/status/render.ts index 01fd716a..6d9d3687 100644 --- a/packages/cli/src/commands/status/render.ts +++ b/packages/cli/src/commands/status/render.ts @@ -168,6 +168,26 @@ export function renderStatusReport( "info", `${report.channels.readyCount}/${report.channels.connections.length} ready`, ], + [ + "memory mcp", + statusLabel(report.memoryMcp.status), + report.memoryMcp.agents.length === 0 + ? "no detected MCP-capable agents" + : report.memoryMcp.agents + .map( + (item) => + `${item.agent}: ${ + item.state === "unsupported" + ? "unsupported (no MCP)" + : item.state === "wired" + ? "wired" + : item.state === "error" + ? `error (${item.detail ?? "unknown"})` + : `not wired${item.detail ? ` (${item.detail})` : ""}` + }`, + ) + .join(" · "), + ], ], maxWidth: process.stdout.columns ?? 120, columnStyles: [chalk.cyan, statusStyle, chalk.dim], diff --git a/packages/cli/src/services/install/mcp/BaseMcpGenerator.ts b/packages/cli/src/services/install/mcp/BaseMcpGenerator.ts index f4840acb..c4f8d026 100644 --- a/packages/cli/src/services/install/mcp/BaseMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/BaseMcpGenerator.ts @@ -1,5 +1,6 @@ +import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; -import { McpAgentGenerator, McpMergePlan } from "./types.js"; +import { McpAgentGenerator, McpConfigScope, McpMergePlan } from "./types.js"; import { deepEqual } from "../../../util/object.js"; /** @@ -7,22 +8,36 @@ import { deepEqual } from "../../../util/object.js"; * * Subclasses provide format-specific conversion, reading, and writing. * The shared plan/apply diff-and-merge logic lives here. + * + * Scope: generators write into a base directory (project root or user home). + * The per-scope relative config path comes from `configPaths`; the default + * scope is "project" so existing callers keep their behavior. */ export abstract class BaseMcpGenerator implements McpAgentGenerator { abstract readonly agentType: EnvironmentCode; + /** Relative config path per scope, "/"-separated. User path only for agents with a verified global surface. */ + protected abstract readonly configPaths: { project: string; user?: string }; + + protected constructor(protected readonly scope: McpConfigScope = "project") {} + + protected resolveConfigPath(baseDir: string): string { + const relative = this.scope === "project" ? this.configPaths.project : this.configPaths.user; + if (!relative) { + throw new Error(`${this.agentType} has no ${this.scope}-scope MCP config path.`); + } + return path.join(baseDir, ...relative.split("/")); + } + protected abstract toAgentFormat(def: McpServerDefinition): Record; - protected abstract readExistingServers(projectRoot: string): Promise>; + protected abstract readExistingServers(baseDir: string): Promise>; protected abstract writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise; - async plan( - servers: Record, - projectRoot: string, - ): Promise { - const existingServers = await this.readExistingServers(projectRoot); + async plan(servers: Record, baseDir: string): Promise { + const existingServers = await this.readExistingServers(baseDir); const plan: McpMergePlan = { agentType: this.agentType, @@ -51,9 +66,9 @@ export abstract class BaseMcpGenerator implements McpAgentGenerator { async apply( plan: McpMergePlan, servers: Record, - projectRoot: string, + baseDir: string, ): Promise { - const existingServers = await this.readExistingServers(projectRoot); + const existingServers = await this.readExistingServers(baseDir); const toWrite = new Set([...plan.newServers, ...plan.resolvedConflicts]); for (const name of toWrite) { @@ -63,6 +78,6 @@ export abstract class BaseMcpGenerator implements McpAgentGenerator { } } - await this.writeServers(projectRoot, existingServers); + await this.writeServers(baseDir, existingServers); } } diff --git a/packages/cli/src/services/install/mcp/ClaudeCodeMcpGenerator.ts b/packages/cli/src/services/install/mcp/ClaudeCodeMcpGenerator.ts index 65572777..2809609f 100644 --- a/packages/cli/src/services/install/mcp/ClaudeCodeMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/ClaudeCodeMcpGenerator.ts @@ -1,7 +1,7 @@ import fs from "fs-extra"; -import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface ClaudeMcpConfig { mcpServers?: Record>; @@ -11,8 +11,18 @@ interface ClaudeMcpConfig { export class ClaudeCodeMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "claude"; + protected readonly configPaths: { project: string; user?: string } = { + project: ".mcp.json", + // Claude Code user-scope servers live in ~/.claude.json (official docs). + user: ".claude.json", + }; + private fullConfig: ClaudeMcpConfig = {}; + constructor(scope: McpConfigScope = "project") { + super(scope); + } + protected toAgentFormat(def: McpServerDefinition): Record { if (def.transport === "stdio") { const entry: Record = { command: def.command! }; @@ -27,10 +37,10 @@ export class ClaudeCodeMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".mcp.json"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { - this.fullConfig = await fs.readJson(configPath); + this.fullConfig = (await fs.readJson(configPath)) as ClaudeMcpConfig; return (this.fullConfig.mcpServers || {}) as Record; } this.fullConfig = {}; @@ -38,10 +48,10 @@ export class ClaudeCodeMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcpServers: mergedServers }; - await fs.writeJson(path.join(projectRoot, ".mcp.json"), output, { spaces: 2 }); + await fs.writeJson(this.resolveConfigPath(baseDir), output, { spaces: 2 }); } } diff --git a/packages/cli/src/services/install/mcp/CodexMcpGenerator.ts b/packages/cli/src/services/install/mcp/CodexMcpGenerator.ts index 72c96235..c4f2fc81 100644 --- a/packages/cli/src/services/install/mcp/CodexMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/CodexMcpGenerator.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as TOML from "smol-toml"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface CodexConfig { mcp_servers?: Record>; @@ -12,8 +13,18 @@ interface CodexConfig { export class CodexMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "codex"; + protected readonly configPaths: { project: string; user?: string } = { + project: ".codex/config.toml", + // Codex global MCP config (official docs): ~/.codex/config.toml. + user: ".codex/config.toml", + }; + private fullConfig: CodexConfig = {}; + constructor(scope: McpConfigScope = "project") { + super(scope); + } + protected toAgentFormat(def: McpServerDefinition): Record { if (def.transport === "stdio") { const entry: Record = { command: def.command! }; @@ -28,11 +39,17 @@ export class CodexMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".codex", "config.toml"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { const content = await fs.readFile(configPath, "utf-8"); - this.fullConfig = TOML.parse(content) as CodexConfig; + try { + this.fullConfig = TOML.parse(content) as CodexConfig; + } catch (error) { + throw new Error( + `Invalid TOML in ${path.relative(baseDir, configPath)}: ${(error as Error).message}`, + ); + } return (this.fullConfig.mcp_servers || {}) as Record; } this.fullConfig = {}; @@ -40,12 +57,92 @@ export class CodexMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { - const output = { ...this.fullConfig, mcp_servers: mergedServers }; - const configPath = path.join(projectRoot, ".codex", "config.toml"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); + + // The user's global ~/.codex/config.toml routinely carries comments and + // hand-tuned formatting. A TOML round-trip would destroy them, so the + // user scope writes textually: append or replace only our own table. + if (this.scope === "user") { + const original = (await fs.pathExists(configPath)) + ? await fs.readFile(configPath, "utf-8") + : ""; + const output = upsertTomlTable(original, "mcp_servers", mergedServers); + try { + TOML.parse(output); + } catch { + throw new Error( + `Refusing to write ${path.relative(baseDir, configPath)}: upsert would produce invalid TOML (check for a conflicting inline [mcp_servers] entry).`, + ); + } + await fs.writeFile(configPath, output, "utf-8"); + return; + } + + const output = { ...this.fullConfig, mcp_servers: mergedServers }; await fs.writeFile(configPath, TOML.stringify(output), "utf-8"); } } + +/** + * Textually upsert `[.]` TOML tables for every entry in + * `servers`, leaving all other bytes of `content` untouched. + * + * - Missing table → appended at the end. + * - Existing table (header line through the line before the next `[` header + * or EOF) → replaced in place. + */ +export function upsertTomlTable( + content: string, + table: string, + servers: Record, +): string { + let output = content; + for (const [name, entry] of Object.entries(servers)) { + const body = entryToTomlBody(entry as Record); + output = replaceTomlTable(output, `${table}.${name}`, body); + } + return output; +} + +function entryToTomlBody(entry: Record): string { + return Object.entries(entry) + .map(([key, value]) => `${key} = ${tomlValue(value)}`) + .join("\n"); +} + +function tomlValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(tomlValue).join(", ")}]`; + return TOML.stringify(value as never).trim(); +} + +function replaceTomlTable(content: string, tablePath: string, body: string): string { + const headerPattern = new RegExp(`^\\s*\\[${escapeRegExp(tablePath)}\\]\\s*$`, "m"); + const match = content.match(headerPattern); + + if (!match) { + const prefix = content.length === 0 || content.endsWith("\n") ? content : `${content}\n`; + const separator = prefix.endsWith("\n\n") || prefix === "" ? "" : "\n"; + return `${prefix}${separator}[${tablePath}]\n${body}\n`; + } + + const start = match.index!; + const after = content.slice(start + match[0].length); + // Table body runs until the next header line or EOF. + const nextHeader = after.match(/^\s*\[/m); + const bodyEnd = nextHeader ? nextHeader.index! : after.length; + const blockBody = after.slice(0, bodyEnd); + const blockAfter = after.slice(bodyEnd); + + const keptAfter = + blockAfter.startsWith("\n") || blockAfter === "" ? blockAfter : `\n${blockAfter}`; + return `${content.slice(0, start)}[${tablePath}]\n${body}\n${keptAfter}`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/cli/src/services/install/mcp/CursorMcpGenerator.ts b/packages/cli/src/services/install/mcp/CursorMcpGenerator.ts new file mode 100644 index 00000000..047f7707 --- /dev/null +++ b/packages/cli/src/services/install/mcp/CursorMcpGenerator.ts @@ -0,0 +1,63 @@ +import fs from "fs-extra"; +import * as path from "path"; +import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; +import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; + +interface CursorMcpConfig { + mcpServers?: Record>; + [key: string]: unknown; +} + +/** + * Cursor MCP config is `mcp.json` → `mcpServers` + * (project: .cursor/mcp.json, global: ~/.cursor/mcp.json; + * official docs: cursor.com/docs/context/mcp). + */ +export class CursorMcpGenerator extends BaseMcpGenerator { + readonly agentType: EnvironmentCode = "cursor"; + + protected readonly configPaths: { project: string; user?: string } = { + project: ".cursor/mcp.json", + user: ".cursor/mcp.json", + }; + + private fullConfig: CursorMcpConfig = {}; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + + protected toAgentFormat(def: McpServerDefinition): Record { + if (def.transport === "stdio") { + const entry: Record = { command: def.command! }; + if (def.args && def.args.length > 0) entry.args = def.args; + if (def.env && Object.keys(def.env).length > 0) entry.env = def.env; + return entry; + } + + const entry: Record = { type: def.transport, url: def.url! }; + if (def.headers && Object.keys(def.headers).length > 0) entry.headers = def.headers; + return entry; + } + + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); + if (await fs.pathExists(configPath)) { + this.fullConfig = (await fs.readJson(configPath)) as CursorMcpConfig; + return (this.fullConfig.mcpServers || {}) as Record; + } + this.fullConfig = {}; + return {}; + } + + protected async writeServers( + baseDir: string, + mergedServers: Record, + ): Promise { + const output = { ...this.fullConfig, mcpServers: mergedServers }; + const configPath = this.resolveConfigPath(baseDir); + await fs.ensureDir(path.dirname(configPath)); + await fs.writeJson(configPath, output, { spaces: 2 }); + } +} diff --git a/packages/cli/src/services/install/mcp/DevinMcpGenerator.ts b/packages/cli/src/services/install/mcp/DevinMcpGenerator.ts index 76e5da72..0c0d1438 100644 --- a/packages/cli/src/services/install/mcp/DevinMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/DevinMcpGenerator.ts @@ -2,6 +2,7 @@ import fs from "fs-extra"; import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface DevinMcpConfig { mcpServers?: Record>; @@ -11,6 +12,14 @@ interface DevinMcpConfig { export class DevinMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "devin"; + protected readonly configPaths: { project: string; user?: string } = { + project: ".devin/config.json", + }; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + private fullConfig: DevinMcpConfig = {}; protected toAgentFormat(def: McpServerDefinition): Record { @@ -26,8 +35,8 @@ export class DevinMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".devin", "config.json"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { this.fullConfig = await fs.readJson(configPath); return (this.fullConfig.mcpServers || {}) as Record; @@ -37,11 +46,11 @@ export class DevinMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcpServers: mergedServers }; - const configPath = path.join(projectRoot, ".devin", "config.json"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); await fs.writeJson(configPath, output, { spaces: 2 }); } diff --git a/packages/cli/src/services/install/mcp/GeminiMcpGenerator.ts b/packages/cli/src/services/install/mcp/GeminiMcpGenerator.ts new file mode 100644 index 00000000..e06a1c64 --- /dev/null +++ b/packages/cli/src/services/install/mcp/GeminiMcpGenerator.ts @@ -0,0 +1,63 @@ +import fs from "fs-extra"; +import * as path from "path"; +import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; +import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; + +interface GeminiSettingsConfig { + mcpServers?: Record>; + [key: string]: unknown; +} + +/** + * Gemini CLI MCP servers live in `settings.json` → `mcpServers` + * (user scope: ~/.gemini/settings.json, workspace scope: .gemini/settings.json; + * official docs: gemini-cli docs/tools/mcp-server.md + docs/cli/settings.md). + */ +export class GeminiMcpGenerator extends BaseMcpGenerator { + readonly agentType: EnvironmentCode = "gemini"; + + protected readonly configPaths: { project: string; user?: string } = { + project: ".gemini/settings.json", + user: ".gemini/settings.json", + }; + + private fullConfig: GeminiSettingsConfig = {}; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + + protected toAgentFormat(def: McpServerDefinition): Record { + if (def.transport === "stdio") { + const entry: Record = { command: def.command! }; + if (def.args && def.args.length > 0) entry.args = def.args; + if (def.env && Object.keys(def.env).length > 0) entry.env = def.env; + return entry; + } + + const entry: Record = { type: def.transport, url: def.url! }; + if (def.headers && Object.keys(def.headers).length > 0) entry.headers = def.headers; + return entry; + } + + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); + if (await fs.pathExists(configPath)) { + this.fullConfig = (await fs.readJson(configPath)) as GeminiSettingsConfig; + return (this.fullConfig.mcpServers || {}) as Record; + } + this.fullConfig = {}; + return {}; + } + + protected async writeServers( + baseDir: string, + mergedServers: Record, + ): Promise { + const output = { ...this.fullConfig, mcpServers: mergedServers }; + const configPath = this.resolveConfigPath(baseDir); + await fs.ensureDir(path.dirname(configPath)); + await fs.writeJson(configPath, output, { spaces: 2 }); + } +} diff --git a/packages/cli/src/services/install/mcp/GitHubCopilotMcpGenerator.ts b/packages/cli/src/services/install/mcp/GitHubCopilotMcpGenerator.ts index b0e9a175..48b3b373 100644 --- a/packages/cli/src/services/install/mcp/GitHubCopilotMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/GitHubCopilotMcpGenerator.ts @@ -3,4 +3,10 @@ import { ClaudeCodeMcpGenerator } from "./ClaudeCodeMcpGenerator.js"; export class GitHubCopilotMcpGenerator extends ClaudeCodeMcpGenerator { readonly agentType: EnvironmentCode = "github"; + + // Project-only: Copilot MCP lives in the repo `.mcp.json`; no verified + // user-scope path exists, so user scope intentionally throws. + protected readonly configPaths: { project: string; user?: string } = { + project: ".mcp.json", + }; } diff --git a/packages/cli/src/services/install/mcp/JunieMcpGenerator.ts b/packages/cli/src/services/install/mcp/JunieMcpGenerator.ts index 0a619a48..9c3cc6d5 100644 --- a/packages/cli/src/services/install/mcp/JunieMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/JunieMcpGenerator.ts @@ -2,6 +2,7 @@ import fs from "fs-extra"; import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface JunieMcpConfig { mcpServers?: Record>; @@ -11,6 +12,14 @@ interface JunieMcpConfig { export class JunieMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "junie"; + protected readonly configPaths: { project: string; user?: string } = { + project: ".junie/mcp/mcp.json", + }; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + private fullConfig: JunieMcpConfig = {}; protected toAgentFormat(def: McpServerDefinition): Record { @@ -26,8 +35,8 @@ export class JunieMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".junie", "mcp", "mcp.json"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { this.fullConfig = await fs.readJson(configPath); return (this.fullConfig.mcpServers || {}) as Record; @@ -37,11 +46,11 @@ export class JunieMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcpServers: mergedServers }; - const configPath = path.join(projectRoot, ".junie", "mcp", "mcp.json"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); await fs.writeJson(configPath, output, { spaces: 2 }); } diff --git a/packages/cli/src/services/install/mcp/KiloCodeMcpGenerator.ts b/packages/cli/src/services/install/mcp/KiloCodeMcpGenerator.ts index e5f99877..318dc900 100644 --- a/packages/cli/src/services/install/mcp/KiloCodeMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/KiloCodeMcpGenerator.ts @@ -2,6 +2,7 @@ import fs from "fs-extra"; import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface KiloMcpConfig { mcp?: Record>; @@ -11,6 +12,14 @@ interface KiloMcpConfig { export class KiloCodeMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "kilocode"; + protected readonly configPaths: { project: string; user?: string } = { + project: ".kilo/kilo.jsonc", + }; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + private fullConfig: KiloMcpConfig = {}; protected toAgentFormat(def: McpServerDefinition): Record { @@ -35,8 +44,8 @@ export class KiloCodeMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".kilo", "kilo.jsonc"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { const content = await fs.readFile(configPath, "utf8"); this.fullConfig = parseJsonc(content); @@ -47,11 +56,11 @@ export class KiloCodeMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcp: mergedServers }; - const configPath = path.join(projectRoot, ".kilo", "kilo.jsonc"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); await fs.writeFile(configPath, `${JSON.stringify(output, null, 2)}\n`); } diff --git a/packages/cli/src/services/install/mcp/McpConfigGenerator.ts b/packages/cli/src/services/install/mcp/McpConfigGenerator.ts index 0068ae89..71131a1b 100644 --- a/packages/cli/src/services/install/mcp/McpConfigGenerator.ts +++ b/packages/cli/src/services/install/mcp/McpConfigGenerator.ts @@ -4,6 +4,8 @@ import { isInteractiveTerminal } from "../../../util/terminal.js"; import { McpAgentGenerator, McpInstallReport, McpMergePlan } from "./types.js"; import { ClaudeCodeMcpGenerator } from "./ClaudeCodeMcpGenerator.js"; import { CodexMcpGenerator } from "./CodexMcpGenerator.js"; +import { GeminiMcpGenerator } from "./GeminiMcpGenerator.js"; +import { CursorMcpGenerator } from "./CursorMcpGenerator.js"; import { JunieMcpGenerator } from "./JunieMcpGenerator.js"; import { GitHubCopilotMcpGenerator } from "./GitHubCopilotMcpGenerator.js"; import { DevinMcpGenerator } from "./DevinMcpGenerator.js"; @@ -20,6 +22,8 @@ export interface McpInstallOptions { const GENERATORS: McpAgentGenerator[] = [ new ClaudeCodeMcpGenerator(), new CodexMcpGenerator(), + new GeminiMcpGenerator(), + new CursorMcpGenerator(), new JunieMcpGenerator(), new GitHubCopilotMcpGenerator(), new DevinMcpGenerator(), diff --git a/packages/cli/src/services/install/mcp/OpenCodeMcpGenerator.ts b/packages/cli/src/services/install/mcp/OpenCodeMcpGenerator.ts index 12ddc600..57543db3 100644 --- a/packages/cli/src/services/install/mcp/OpenCodeMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/OpenCodeMcpGenerator.ts @@ -2,6 +2,7 @@ import fs from "fs-extra"; import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface OpenCodeConfig { mcp?: Record>; @@ -11,8 +12,18 @@ interface OpenCodeConfig { export class OpenCodeMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "opencode"; + protected readonly configPaths: { project: string; user?: string } = { + project: "opencode.json", + // OpenCode global config (official docs): ~/.config/opencode/opencode.json. + user: ".config/opencode/opencode.json", + }; + private fullConfig: OpenCodeConfig = {}; + constructor(scope: McpConfigScope = "project") { + super(scope); + } + protected toAgentFormat(def: McpServerDefinition): Record { if (def.transport === "stdio") { const entry: Record = { @@ -33,8 +44,8 @@ export class OpenCodeMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, "opencode.json"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { const content = await fs.readFile(configPath, "utf8"); this.fullConfig = JSON.parse(content || "{}") as OpenCodeConfig; @@ -45,11 +56,11 @@ export class OpenCodeMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcp: mergedServers }; - const configPath = path.join(projectRoot, "opencode.json"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); await fs.writeFile(configPath, `${JSON.stringify(output, null, 2)}\n`); } diff --git a/packages/cli/src/services/install/mcp/RooCodeMcpGenerator.ts b/packages/cli/src/services/install/mcp/RooCodeMcpGenerator.ts index a0087c9e..7a1514f0 100644 --- a/packages/cli/src/services/install/mcp/RooCodeMcpGenerator.ts +++ b/packages/cli/src/services/install/mcp/RooCodeMcpGenerator.ts @@ -2,6 +2,7 @@ import fs from "fs-extra"; import * as path from "path"; import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; import { BaseMcpGenerator } from "./BaseMcpGenerator.js"; +import { McpConfigScope } from "./types.js"; interface RooMcpConfig { mcpServers?: Record>; @@ -11,6 +12,12 @@ interface RooMcpConfig { export class RooCodeMcpGenerator extends BaseMcpGenerator { readonly agentType: EnvironmentCode = "roo"; + protected readonly configPaths: { project: string; user?: string } = { project: ".roo/mcp.json" }; + + constructor(scope: McpConfigScope = "project") { + super(scope); + } + private fullConfig: RooMcpConfig = {}; protected toAgentFormat(def: McpServerDefinition): Record { @@ -26,8 +33,8 @@ export class RooCodeMcpGenerator extends BaseMcpGenerator { return entry; } - protected async readExistingServers(projectRoot: string): Promise> { - const configPath = path.join(projectRoot, ".roo", "mcp.json"); + protected async readExistingServers(baseDir: string): Promise> { + const configPath = this.resolveConfigPath(baseDir); if (await fs.pathExists(configPath)) { this.fullConfig = await fs.readJson(configPath); return (this.fullConfig.mcpServers || {}) as Record; @@ -37,11 +44,11 @@ export class RooCodeMcpGenerator extends BaseMcpGenerator { } protected async writeServers( - projectRoot: string, + baseDir: string, mergedServers: Record, ): Promise { const output = { ...this.fullConfig, mcpServers: mergedServers }; - const configPath = path.join(projectRoot, ".roo", "mcp.json"); + const configPath = this.resolveConfigPath(baseDir); await fs.ensureDir(path.dirname(configPath)); await fs.writeJson(configPath, output, { spaces: 2 }); } diff --git a/packages/cli/src/services/install/mcp/types.ts b/packages/cli/src/services/install/mcp/types.ts index ec52f9be..3c4c3481 100644 --- a/packages/cli/src/services/install/mcp/types.ts +++ b/packages/cli/src/services/install/mcp/types.ts @@ -1,5 +1,8 @@ import { EnvironmentCode, McpServerDefinition } from "../../../types.js"; +/** Config target scope: project root or user home directory. */ +export type McpConfigScope = "project" | "user"; + export interface McpMergePlan { agentType: EnvironmentCode; newServers: string[]; diff --git a/packages/cli/src/services/setup/memory-mcp/grok-writer.ts b/packages/cli/src/services/setup/memory-mcp/grok-writer.ts new file mode 100644 index 00000000..eaeb6cec --- /dev/null +++ b/packages/cli/src/services/setup/memory-mcp/grok-writer.ts @@ -0,0 +1,120 @@ +import fs from "fs-extra"; +import { join } from "path"; +import { deepEqual } from "../../../util/object.js"; +import { MEMORY_MCP_SERVER } from "./spec.js"; +import type { + GlobalMcpWriter, + MemoryMcpApplyResult, + MemoryMcpInspectResult, + MemoryMcpServerSpec, +} from "./spec.js"; + +interface GrokServerEntry { + id: string; + label: string; + enabled: boolean; + transport: "stdio" | "http" | "sse"; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + [key: string]: unknown; +} + +interface GrokUserSettings { + mcp?: { servers?: GrokServerEntry[] }; + [key: string]: unknown; +} + +const CONFIG_PATH = ".grok/user-settings.json"; + +function toGrokEntry(spec: MemoryMcpServerSpec): GrokServerEntry { + return { + id: spec.name, + label: "AI DevKit Memory", + enabled: true, + transport: "stdio", + command: spec.command, + args: [...spec.args], + }; +} + +async function readSettings(homeDir: string): Promise { + const configPath = join(homeDir, ...CONFIG_PATH.split("/")); + if (!(await fs.pathExists(configPath))) { + return {}; + } + try { + const parsed = await fs.readJson(configPath); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Expected a JSON object in ${CONFIG_PATH}.`); + } + return parsed as GrokUserSettings; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Invalid JSON in ~/${CONFIG_PATH}: ${(error as Error).message}`); + } + throw error; + } +} + +function readServers(settings: GrokUserSettings): GrokServerEntry[] { + const servers = settings.mcp?.servers; + if (servers === undefined) { + return []; + } + if (!Array.isArray(servers)) { + throw new Error(`Expected an array at "mcp.servers" in ~/${CONFIG_PATH}.`); + } + return servers; +} + +/** + * Grok CLI stores user-level MCP servers as an ARRAY in + * `~/.grok/user-settings.json` under `mcp.servers`, upserted by `id` + * (verified against superagent-ai/grok-cli src/utils/settings.ts). + */ +export const grokGlobalMcpWriter: GlobalMcpWriter = { + agent: "grok", + configPath: CONFIG_PATH, + + async apply(spec: MemoryMcpServerSpec, homeDir: string): Promise { + const settings = await readSettings(homeDir); + const servers = readServers(settings); + const entry = toGrokEntry(spec); + const index = servers.findIndex((server) => server?.id === spec.name); + + if (index >= 0 && deepEqual(entry, servers[index])) { + return { status: "skipped", message: `Already configured in ~/${CONFIG_PATH}.` }; + } + + const merged = [...servers]; + if (index >= 0) { + merged[index] = entry; + } else { + merged.push(entry); + } + settings.mcp = { ...settings.mcp, servers: merged }; + const configPath = join(homeDir, ...CONFIG_PATH.split("/")); + await fs.ensureDir(join(configPath, "..")); + await fs.writeFile(configPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); + return { status: "installed", message: `Configured in ~/${CONFIG_PATH}.` }; + }, + + async inspect(homeDir: string): Promise { + try { + const settings = await readSettings(homeDir); + const current = readServers(settings).find((server) => server?.id === MEMORY_MCP_SERVER.name); + if (current === undefined) { + return { state: "unwired" }; + } + return deepEqual(toGrokEntry(MEMORY_MCP_SERVER), current) + ? { state: "wired" } + : { state: "unwired", detail: "configured with a custom entry" }; + } catch (error) { + return { state: "error", detail: error instanceof Error ? error.message : String(error) }; + } + }, +}; diff --git a/packages/cli/src/services/setup/memory-mcp/index.ts b/packages/cli/src/services/setup/memory-mcp/index.ts new file mode 100644 index 00000000..bc9a87b4 --- /dev/null +++ b/packages/cli/src/services/setup/memory-mcp/index.ts @@ -0,0 +1,105 @@ +import type { McpServerDefinition } from "../../../types.js"; +import { ClaudeCodeMcpGenerator } from "../../install/mcp/ClaudeCodeMcpGenerator.js"; +import { CodexMcpGenerator } from "../../install/mcp/CodexMcpGenerator.js"; +import { CursorMcpGenerator } from "../../install/mcp/CursorMcpGenerator.js"; +import { GeminiMcpGenerator } from "../../install/mcp/GeminiMcpGenerator.js"; +import { OpenCodeMcpGenerator } from "../../install/mcp/OpenCodeMcpGenerator.js"; +import type { BaseMcpGenerator } from "../../install/mcp/BaseMcpGenerator.js"; +import type { McpMergePlan } from "../../install/mcp/types.js"; +import { grokGlobalMcpWriter } from "./grok-writer.js"; +import { MEMORY_MCP_SERVER } from "./spec.js"; +import type { + GlobalMcpWriter, + MemoryMcpApplyResult, + MemoryMcpInspectResult, + MemoryMcpServerSpec, +} from "./spec.js"; + +export { MEMORY_MCP_SERVER, MCP_CAPABLE_AGENTS, MCP_UNSUPPORTED_AGENTS } from "./spec.js"; +export type { + GlobalMcpWriter, + MemoryMcpApplyResult, + MemoryMcpApplyStatus, + MemoryMcpInspectResult, + MemoryMcpInspectState, + MemoryMcpServerSpec, +} from "./spec.js"; + +function toDefinition(spec: MemoryMcpServerSpec): McpServerDefinition { + return { transport: "stdio", command: spec.command, args: [...spec.args] }; +} + +/** Shared interpretation of a single-server merge plan in our namespace. */ +type PlanState = "present" | "missing" | "conflict"; + +/** + * Interpret a merge plan for OUR server name only: `missing` (not + * configured), `conflict` (configured with a custom entry), or `present` + * (already matches the canonical memory server definition). Single source + * of truth for both apply (skip vs install) and inspect (wired vs unwired). + */ +function planToState(plan: McpMergePlan): PlanState { + if (plan.newServers.length > 0) { + return "missing"; + } + if (plan.conflictServers.length > 0) { + return "conflict"; + } + return "present"; +} + +/** + * Adapts a user-scope install/mcp generator to the setup-facing + * GlobalMcpWriter contract. Reuses toAgentFormat + plan/apply diff-and-merge; + * drift on OUR server name is always overwritten (our namespace only) and + * setup never prompts. + */ +function generatorAsWriter(generator: BaseMcpGenerator, configPath: string): GlobalMcpWriter { + const serversOf = (spec: MemoryMcpServerSpec) => ({ [spec.name]: toDefinition(spec) }); + + return { + agent: generator.agentType, + configPath, + + async apply(spec: MemoryMcpServerSpec, homeDir: string): Promise { + const servers = serversOf(spec); + const plan = await generator.plan(servers, homeDir); + + if (planToState(plan) === "present") { + return { status: "skipped", message: `Already configured in ~/${configPath}.` }; + } + + plan.resolvedConflicts = [...plan.conflictServers]; + await generator.apply(plan, servers, homeDir); + return { status: "installed", message: `Configured in ~/${configPath}.` }; + }, + + async inspect(homeDir: string): Promise { + try { + const state = planToState(await generator.plan(serversOf(MEMORY_MCP_SERVER), homeDir)); + if (state === "missing") { + return { state: "unwired" }; + } + if (state === "conflict") { + return { state: "unwired", detail: "configured with a custom entry" }; + } + return { state: "wired" }; + } catch (error) { + return { state: "error", detail: error instanceof Error ? error.message : String(error) }; + } + }, + }; +} + +const WRITERS: Record = { + claude: generatorAsWriter(new ClaudeCodeMcpGenerator("user"), ".claude.json"), + codex: generatorAsWriter(new CodexMcpGenerator("user"), ".codex/config.toml"), + gemini: generatorAsWriter(new GeminiMcpGenerator("user"), ".gemini/settings.json"), + cursor: generatorAsWriter(new CursorMcpGenerator("user"), ".cursor/mcp.json"), + opencode: generatorAsWriter(new OpenCodeMcpGenerator("user"), ".config/opencode/opencode.json"), + grok: grokGlobalMcpWriter, +}; + +export function getGlobalMcpWriter(agent: string): GlobalMcpWriter | undefined { + return WRITERS[agent]; +} diff --git a/packages/cli/src/services/setup/memory-mcp/spec.ts b/packages/cli/src/services/setup/memory-mcp/spec.ts new file mode 100644 index 00000000..3c2330f6 --- /dev/null +++ b/packages/cli/src/services/setup/memory-mcp/spec.ts @@ -0,0 +1,56 @@ +export interface MemoryMcpServerSpec { + /** Server name/key used in every harness config. */ + name: string; + command: string; + args: string[]; +} + +/** + * Canonical launch definition for the ai-devkit memory MCP server. + * + * `npx -y @ai-devkit/memory` runs the published `ai-devkit-memory` bin at the + * latest version from the npm cache/registry, so user configs never churn on + * release and memory works in every harness with zero manual steps. + */ +export const MEMORY_MCP_SERVER: MemoryMcpServerSpec = { + name: "ai-devkit-memory", + command: "npx", + args: ["-y", "@ai-devkit/memory"], +}; + +/** Agents with a verified user-level MCP config surface. */ +export const MCP_CAPABLE_AGENTS = [ + "claude", + "codex", + "gemini", + "cursor", + "opencode", + "grok", +] as const; + +/** Agents that verifiably have no MCP support, with the honest skip reason. */ +export const MCP_UNSUPPORTED_AGENTS: Record = { + pi: "pi has no MCP support by design. Use the 'memory' skill or 'ai-devkit memory' CLI instead.", +}; + +export type MemoryMcpApplyStatus = "installed" | "skipped"; + +export interface MemoryMcpApplyResult { + status: MemoryMcpApplyStatus; + message: string; +} + +export type MemoryMcpInspectState = "wired" | "unwired" | "error"; + +export interface MemoryMcpInspectResult { + state: MemoryMcpInspectState; + detail?: string; +} + +export interface GlobalMcpWriter { + readonly agent: string; + /** Config path relative to the user's home directory. */ + readonly configPath: string; + apply(spec: MemoryMcpServerSpec, homeDir: string): Promise; + inspect(homeDir: string): Promise; +} diff --git a/packages/cli/src/services/setup/setup.service.ts b/packages/cli/src/services/setup/setup.service.ts index 7f4236f7..91eb3ac7 100644 --- a/packages/cli/src/services/setup/setup.service.ts +++ b/packages/cli/src/services/setup/setup.service.ts @@ -7,6 +7,11 @@ import { promisify } from "util"; import { BUILTIN_SKILL_REGISTRY, getBuiltinSkillNames } from "../skill/skill-builtins.js"; import { ConfigManager } from "../../lib/Config.js"; import { SkillService } from "../../services/skill/skill.service.js"; +import { + getGlobalMcpWriter, + MCP_UNSUPPORTED_AGENTS, + MEMORY_MCP_SERVER, +} from "./memory-mcp/index.js"; import { getErrorMessage } from "../../util/text.js"; const execFileAsync = promisify(execFile); @@ -14,7 +19,15 @@ const CODEX_HOOK_COMMAND = "node ~/.codex/hooks/codex-session-mapping.cjs"; const PI_TRACKER_PACKAGE = "npm:@ai-devkit/pi-session-tracker"; const CLAUDE_PROMPT_HOOK_COMMAND = "node ~/.claude/hooks/claude-prompt-hook.js"; -export const SUPPORTED_SETUP_AGENTS = ["codex", "pi", "claude"] as const; +export const SUPPORTED_SETUP_AGENTS = [ + "codex", + "pi", + "claude", + "gemini", + "cursor", + "opencode", + "grok", +] as const; export type SetupAgent = (typeof SUPPORTED_SETUP_AGENTS)[number]; export type SetupStepStatus = "installed" | "skipped" | "failed"; @@ -136,6 +149,7 @@ const setupDefinitions: AgentSetupDefinition[] = [ steps: [ { name: "codex-session-hook", run: setupCodexSessionHook }, { name: "built-in-skills", run: setupBuiltInSkills }, + { name: "memory-mcp", run: setupMemoryMcp }, ], }, { @@ -144,6 +158,7 @@ const setupDefinitions: AgentSetupDefinition[] = [ steps: [ { name: "pi-session-tracker", run: setupPiSessionTracker }, { name: "built-in-skills", run: setupBuiltInSkills }, + { name: "memory-mcp", run: setupMemoryMcp }, ], }, { @@ -152,8 +167,29 @@ const setupDefinitions: AgentSetupDefinition[] = [ steps: [ { name: "claude-prompt-hook", run: setupClaudePromptHook }, { name: "built-in-skills", run: setupBuiltInSkills }, + { name: "memory-mcp", run: setupMemoryMcp }, ], }, + { + agent: "gemini", + dotFolder: ".gemini", + steps: [{ name: "memory-mcp", run: setupMemoryMcp }], + }, + { + agent: "cursor", + dotFolder: ".cursor", + steps: [{ name: "memory-mcp", run: setupMemoryMcp }], + }, + { + agent: "opencode", + dotFolder: ".config/opencode", + steps: [{ name: "memory-mcp", run: setupMemoryMcp }], + }, + { + agent: "grok", + dotFolder: ".grok", + steps: [{ name: "memory-mcp", run: setupMemoryMcp }], + }, ]; async function setupCodexSessionHook(context: SetupStepContext): Promise { @@ -268,6 +304,32 @@ async function setupBuiltInSkills( return installed(agent, "built-in-skills", `Installed AI DevKit built-in skills for ${agent}.`); } +/** Home-relative marker folders used to detect which agents are installed. */ +export const SETUP_AGENT_DOT_FOLDERS: Record = Object.fromEntries( + setupDefinitions.map((definition) => [definition.agent, definition.dotFolder]), +); + +async function setupMemoryMcp( + context: SetupStepContext, + agent: SetupAgent, +): Promise { + const writer = getGlobalMcpWriter(agent); + + if (!writer) { + const reason = MCP_UNSUPPORTED_AGENTS[agent]; + return skipped( + agent, + "memory-mcp", + reason ?? `${agent} has no verified global MCP config surface; skipping memory MCP wiring.`, + ); + } + + const result = await writer.apply(MEMORY_MCP_SERVER, context.homeDir); + return result.status === "installed" + ? installed(agent, "memory-mcp", `${result.message} (memory tools: store/search/update)`) + : skipped(agent, "memory-mcp", result.message); +} + async function readHooksJson(hooksJsonPath: string): Promise { if (!(await fs.pathExists(hooksJsonPath))) { return { hooks: {} }; diff --git a/packages/cli/src/services/status/status.service.ts b/packages/cli/src/services/status/status.service.ts index 3e8eeefd..82ace504 100644 --- a/packages/cli/src/services/status/status.service.ts +++ b/packages/cli/src/services/status/status.service.ts @@ -17,6 +17,11 @@ import { getBuiltinSkillNames } from "../skill/skill-builtins.js"; import { filterStringRecord } from "../../util/config.js"; import { getGlobalSkillPath, isValidEnvironmentCode } from "../../util/env.js"; import { inspectTmux } from "../../util/tmux.js"; +import { SETUP_AGENT_DOT_FOLDERS } from "../setup/setup.service.js"; +import { + getGlobalMcpWriter, + MCP_UNSUPPORTED_AGENTS, +} from "../setup/memory-mcp/index.js"; import packageJson from "../../../package.json" with { type: "json" }; const execFileAsync = promisify(execFile); @@ -89,9 +94,21 @@ export interface StatusReport { tmux: TmuxCheck; registries: RegistriesCheck; channels: ChannelsCheck; + memoryMcp: MemoryMcpCheck; checks: { passed: number; warnings: number; failed: number }; } +export interface MemoryMcpAgentCheck { + agent: string; + state: "wired" | "unwired" | "unsupported" | "error"; + detail?: string; +} + +export interface MemoryMcpCheck { + status: CheckStatus; + agents: MemoryMcpAgentCheck[]; +} + export interface StatusServiceOptions { cwd?: string; homeDir?: string; @@ -498,9 +515,50 @@ function leafStatuses( report.project.config.status, ...agentStatuses, report.tmux.status, + report.memoryMcp.status, ]; } +async function memoryMcpCheck(rt: Runtime): Promise { + const agents: MemoryMcpAgentCheck[] = []; + + for (const [agent, dotFolder] of Object.entries(SETUP_AGENT_DOT_FOLDERS)) { + const dotFolderPath = join(rt.homeDir, ...dotFolder.split("/")); + let detected = true; + try { + await rt.access(dotFolderPath); + } catch { + detected = false; + } + if (!detected) { + continue; + } + + const writer = getGlobalMcpWriter(agent); + if (!writer) { + const reason = MCP_UNSUPPORTED_AGENTS[agent]; + agents.push( + reason + ? { agent, state: "unsupported", detail: reason } + : { agent, state: "unsupported" }, + ); + continue; + } + + const inspected = await writer.inspect(rt.homeDir); + if (inspected.state === "error") { + agents.push({ agent, state: "error", detail: inspected.detail }); + continue; + } + agents.push({ agent, state: inspected.state, detail: inspected.detail }); + } + + const degraded = agents.some( + (item) => item.state === "unwired" || item.state === "error", + ); + return { status: degraded ? "warn" : "pass", agents }; +} + export async function getStatusReport( options: StatusServiceOptions = {}, ): Promise { @@ -518,7 +576,7 @@ export async function getStatusReport( runCommand: rt.runCommand, codexAuth: rt.codexAuth, }; - const [project, agents, aiDevkit, tmux, globalRegistry, channels] = + const [project, agents, aiDevkit, tmux, globalRegistry, channels, memoryMcp] = await Promise.all([ projectPromise, getAgentReadinessReports(agentOptions), @@ -526,6 +584,7 @@ export async function getStatusReport( tmuxCheck(rt), globalRegistries(rt), channelsCheck(rt), + memoryMcpCheck(rt), ]); const registries: RegistriesCheck = { project: projectRegistries(project.raw, project.check.path), @@ -539,6 +598,7 @@ export async function getStatusReport( tmux, registries, channels, + memoryMcp, }; const statuses = leafStatuses(partial); return { diff --git a/packages/cli/src/util/env.ts b/packages/cli/src/util/env.ts index 0c3aa6db..f18f91c4 100644 --- a/packages/cli/src/util/env.ts +++ b/packages/cli/src/util/env.ts @@ -6,6 +6,7 @@ export const ENVIRONMENT_DEFINITIONS: Record tool.name === "memory_searchKnowledge")!; +const STORE = TOOLS.find((tool) => tool.name === "memory_storeKnowledge")!; +const UPDATE = TOOLS.find((tool) => tool.name === "memory_updateKnowledge")!; + +describe("memory MCP tool descriptions (always-loaded prompt surface)", () => { + it("keeps the three tool names and schemas stable", () => { + expect(TOOLS.map((tool) => tool.name)).toEqual([ + "memory_storeKnowledge", + "memory_updateKnowledge", + "memory_searchKnowledge", + ]); + expect(Object.keys(SEARCH.inputSchema.properties)).toEqual([ + "query", + "contextTags", + "scope", + "limit", + "explain", + ]); + }); + + it("instructs the agent to search BEFORE non-trivial tasks, with an example", () => { + expect(SEARCH.description).toMatch(/BEFORE starting any non-trivial task/i); + expect(SEARCH.description).toMatch(/e\.g\.|example|such as/i); + expect(SEARCH.description!.length).toBeGreaterThan(80); + }); + + it("instructs the agent to store verified reusable knowledge after meaningful work", () => { + expect(STORE.description).toMatch( + /after completing|when you (have )?(just )?(verified|learned|solved)/i, + ); + expect(STORE.description).toMatch(/reusable/i); + }); + + it("instructs the agent to update instead of duplicating when knowledge is wrong", () => { + expect(UPDATE.description).toMatch(/instead of (storing|creating) (a )?(duplicate|new)/i); + }); +});