Merge pull request #493 from thedotmack/cursor-hooks-integration
feat(cursor): Complete Cursor IDE integration with cross-platform hooks
This commit is contained in:
+2
-1
@@ -19,4 +19,5 @@ datasets/
|
||||
src/ui/viewer.html
|
||||
|
||||
# Local MCP server config (for development only)
|
||||
.mcp.json
|
||||
.mcp.json
|
||||
.cursor/
|
||||
@@ -0,0 +1,3 @@
|
||||
# Ignore backup files created by sed
|
||||
*.bak
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# Context Injection in Cursor Hooks
|
||||
|
||||
## The Solution: Auto-Updated Rules File
|
||||
|
||||
Context is automatically injected via Cursor's **Rules** system:
|
||||
|
||||
1. **Install**: `claude-mem cursor install` creates initial context file
|
||||
2. **Stop hook**: `session-summary.sh` updates context after each session ends
|
||||
3. **Cursor**: Automatically includes `.cursor/rules/claude-mem-context.mdc` in all chats
|
||||
|
||||
**Result**: Context appears at the start of every conversation, just like Claude Code!
|
||||
|
||||
## How It Works
|
||||
|
||||
### Installation Creates Initial Context
|
||||
|
||||
```bash
|
||||
claude-mem cursor install
|
||||
```
|
||||
|
||||
This:
|
||||
1. Copies hook scripts to `.cursor/hooks/`
|
||||
2. Creates `hooks.json` configuration
|
||||
3. Fetches existing context from claude-mem and writes to `.cursor/rules/claude-mem-context.mdc`
|
||||
|
||||
### Context Updates at Three Points
|
||||
|
||||
Context is refreshed **three times** per session for maximum freshness:
|
||||
|
||||
1. **Before prompt submission** (`context-inject.sh`): Ensures you start with the latest context from previous sessions
|
||||
2. **After summary completes** (worker auto-update): Immediately after the summary is saved, worker updates the context file
|
||||
3. **After session ends** (`session-summary.sh`): Fallback update in case worker update was missed
|
||||
|
||||
### Before Prompt Hook Updates Context
|
||||
|
||||
When you submit a prompt, `context-inject.sh`:
|
||||
|
||||
```bash
|
||||
# 1. Ensure worker is running
|
||||
ensure_worker_running "$worker_port"
|
||||
|
||||
# 2. Fetch fresh context
|
||||
context=$(curl -s ".../api/context/inject?project=...")
|
||||
|
||||
# 3. Write to rules file (used immediately by Cursor)
|
||||
cat > .cursor/rules/claude-mem-context.mdc << EOF
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# Memory Context
|
||||
${context}
|
||||
EOF
|
||||
```
|
||||
|
||||
### Stop Hook Updates Context
|
||||
|
||||
After each session ends, `session-summary.sh`:
|
||||
|
||||
```bash
|
||||
# 1. Generate session summary
|
||||
curl -X POST .../api/sessions/summarize
|
||||
|
||||
# 2. Fetch fresh context (includes new observations)
|
||||
context=$(curl -s ".../api/context/inject?project=...")
|
||||
|
||||
# 3. Write to rules file for next session
|
||||
cat > .cursor/rules/claude-mem-context.mdc << EOF
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
# Memory Context
|
||||
${context}
|
||||
EOF
|
||||
```
|
||||
|
||||
### The Rules File
|
||||
|
||||
Located at: `.cursor/rules/claude-mem-context.mdc`
|
||||
|
||||
```markdown
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
[Your context from claude-mem appears here]
|
||||
|
||||
---
|
||||
*Updated after last session.*
|
||||
```
|
||||
|
||||
### Update Flow
|
||||
|
||||
Context updates at **three points**:
|
||||
|
||||
**Before each prompt:**
|
||||
1. User submits a prompt
|
||||
2. `beforeSubmitPrompt` hook runs `context-inject.sh`
|
||||
3. Context file refreshed with latest observations from previous sessions
|
||||
4. Cursor reads the updated rules file
|
||||
|
||||
**After summary completes (worker auto-update):**
|
||||
1. Summary is saved to database
|
||||
2. Worker checks if project is registered for Cursor
|
||||
3. If yes, immediately writes updated context file with new observations
|
||||
4. No hook involved - happens in the worker process
|
||||
|
||||
**After session ends (fallback):**
|
||||
1. Agent completes (loop ends)
|
||||
2. `stop` hook runs `session-summary.sh`
|
||||
3. Context file updated (ensures nothing was missed)
|
||||
4. Ready for next session
|
||||
|
||||
## Project Registry
|
||||
|
||||
When you run `claude-mem cursor install`, the project is registered in `~/.claude-mem/cursor-projects.json`. This allows the worker to automatically update your context file whenever a new summary is generated - even if it happens from Claude Code or another IDE working on the same project.
|
||||
|
||||
To see registered projects:
|
||||
```bash
|
||||
cat ~/.claude-mem/cursor-projects.json
|
||||
```
|
||||
|
||||
## Comparison with Claude Code
|
||||
|
||||
| Feature | Claude Code | Cursor |
|
||||
|---------|-------------|--------|
|
||||
| Context injection | ✅ `additionalContext` in hook output | ✅ Auto-updated rules file |
|
||||
| Injection timing | Immediate (same prompt) | Before prompt + after summary + after session |
|
||||
| Persistence | Session only | File-based (persists across restarts) |
|
||||
| Initial setup | Automatic | `claude-mem cursor install` creates initial context |
|
||||
| MCP tool access | ✅ Full support | ✅ Full support |
|
||||
| Web viewer | ✅ Available | ✅ Available |
|
||||
|
||||
## First Session Behavior
|
||||
|
||||
When you run `claude-mem cursor install`:
|
||||
- If worker is running with existing memory → initial context is generated
|
||||
- If no existing memory → placeholder file created
|
||||
|
||||
Context is then automatically refreshed:
|
||||
- Before each prompt (ensures latest observations are included)
|
||||
- After each session ends (captures new observations from the session)
|
||||
|
||||
## Additional Access Methods
|
||||
|
||||
### 1. MCP Tools
|
||||
|
||||
Configure claude-mem's MCP server in Cursor for search tools:
|
||||
- `search(query, project, limit)`
|
||||
- `timeline(anchor, depth_before, depth_after)`
|
||||
- `get_observations(ids)`
|
||||
|
||||
### 2. Web Viewer
|
||||
|
||||
Access context manually at `http://localhost:37777`
|
||||
|
||||
### 3. Manual Request
|
||||
|
||||
Ask the agent: "Check claude-mem for any previous work on authentication"
|
||||
|
||||
## File Location
|
||||
|
||||
The context file is created at:
|
||||
```
|
||||
<workspace>/.cursor/rules/claude-mem-context.mdc
|
||||
```
|
||||
|
||||
This is version-controlled by default. Add to `.gitignore` if you don't want to commit it:
|
||||
```
|
||||
.cursor/rules/claude-mem-context.mdc
|
||||
```
|
||||
@@ -0,0 +1,251 @@
|
||||
# Claude-Mem ↔ Cursor Integration Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This integration connects claude-mem's persistent memory system to Cursor's hook system, enabling:
|
||||
- Automatic capture of agent actions (MCP tools, shell commands, file edits)
|
||||
- Context retrieval from past sessions
|
||||
- Session summarization for future reference
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Cursor │
|
||||
│ Agent │
|
||||
└──────┬──────┘
|
||||
│
|
||||
│ Events (MCP, Shell, File Edits, Prompts)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Cursor Hooks System │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ beforeSubmitPrompt │ │
|
||||
│ │ afterMCPExecution │ │
|
||||
│ │ afterShellExecution │ │
|
||||
│ │ afterFileEdit │ │
|
||||
│ │ stop │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────┬──────────────────────────────┘
|
||||
│
|
||||
│ HTTP Requests
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Hook Scripts (Bash) │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ session-init.sh │ │
|
||||
│ │ context-inject.sh │ │
|
||||
│ │ save-observation.sh │ │
|
||||
│ │ save-file-edit.sh │ │
|
||||
│ │ session-summary.sh │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────┬──────────────────────────────┘
|
||||
│
|
||||
│ HTTP API Calls
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Claude-Mem Worker Service │
|
||||
│ (Port 37777) │
|
||||
│ ┌────────────────────────────────┐ │
|
||||
│ │ /api/sessions/init │ │
|
||||
│ │ /api/sessions/observations │ │
|
||||
│ │ /api/sessions/summarize │ │
|
||||
│ │ /api/context/inject │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
└──────┬──────────────────────────────┘
|
||||
│
|
||||
│ Database Operations
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ SQLite Database │
|
||||
│ + Chroma Vector DB │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Event Flow
|
||||
|
||||
### 1. Prompt Submission Flow
|
||||
|
||||
```
|
||||
User submits prompt
|
||||
↓
|
||||
beforeSubmitPrompt hook fires
|
||||
↓
|
||||
session-init.sh
|
||||
├─ Extract conversation_id, project name
|
||||
├─ POST /api/sessions/init
|
||||
└─ Initialize session in claude-mem
|
||||
↓
|
||||
context-inject.sh
|
||||
├─ GET /api/context/inject?project=...
|
||||
└─ Fetch relevant context (for future use)
|
||||
↓
|
||||
Prompt proceeds to agent
|
||||
```
|
||||
|
||||
### 2. Tool Execution Flow
|
||||
|
||||
```
|
||||
Agent executes MCP tool or shell command
|
||||
↓
|
||||
afterMCPExecution / afterShellExecution hook fires
|
||||
↓
|
||||
save-observation.sh
|
||||
├─ Extract tool_name, tool_input, tool_response
|
||||
├─ Map to claude-mem observation format
|
||||
├─ POST /api/sessions/observations
|
||||
└─ Store observation in database
|
||||
```
|
||||
|
||||
### 3. File Edit Flow
|
||||
|
||||
```
|
||||
Agent edits file
|
||||
↓
|
||||
afterFileEdit hook fires
|
||||
↓
|
||||
save-file-edit.sh
|
||||
├─ Extract file_path, edits
|
||||
├─ Create "write_file" observation
|
||||
├─ POST /api/sessions/observations
|
||||
└─ Store file edit observation
|
||||
```
|
||||
|
||||
### 4. Session End Flow
|
||||
|
||||
```
|
||||
Agent loop ends
|
||||
↓
|
||||
stop hook fires
|
||||
↓
|
||||
session-summary.sh
|
||||
├─ POST /api/sessions/summarize
|
||||
└─ Generate session summary for future retrieval
|
||||
```
|
||||
|
||||
## Data Mapping
|
||||
|
||||
### Session ID Mapping
|
||||
|
||||
| Cursor Field | Claude-Mem Field | Notes |
|
||||
|-------------|------------------|-------|
|
||||
| `conversation_id` | `contentSessionId` | Stable across turns, used as primary session identifier |
|
||||
| `generation_id` | (fallback) | Used if conversation_id unavailable |
|
||||
|
||||
### Tool Mapping
|
||||
|
||||
| Cursor Event | Claude-Mem Tool Name | Input Format |
|
||||
|-------------|---------------------|--------------|
|
||||
| `afterMCPExecution` | `tool_name` from event | `tool_input` as JSON |
|
||||
| `afterShellExecution` | `"Bash"` | `{command: "..."}` |
|
||||
| `afterFileEdit` | `"write_file"` | `{file_path: "...", edits: [...]}` |
|
||||
|
||||
### Project Mapping
|
||||
|
||||
| Source | Target | Notes |
|
||||
|--------|--------|-------|
|
||||
| `workspace_roots[0]` | Project name | Basename of workspace root directory |
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
### Session Management
|
||||
- `POST /api/sessions/init` - Initialize new session
|
||||
- `POST /api/sessions/summarize` - Generate session summary
|
||||
|
||||
### Observation Storage
|
||||
- `POST /api/sessions/observations` - Store tool usage observation
|
||||
|
||||
### Context Retrieval
|
||||
- `GET /api/context/inject?project=...` - Get relevant context for injection
|
||||
|
||||
### Health Checks
|
||||
- `GET /api/readiness` - Check if worker is ready
|
||||
|
||||
## Configuration
|
||||
|
||||
### Worker Settings
|
||||
Located in `~/.claude-mem/settings.json`:
|
||||
- `CLAUDE_MEM_WORKER_PORT` (default: 37777)
|
||||
- `CLAUDE_MEM_WORKER_HOST` (default: 127.0.0.1)
|
||||
|
||||
### Hook Settings
|
||||
Located in `hooks.json`:
|
||||
- Hook event names
|
||||
- Script paths (relative or absolute)
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Worker Unavailable
|
||||
- Hooks poll `/api/readiness` with 30 retries (6 seconds)
|
||||
- If worker unavailable, hooks fail gracefully (exit 0)
|
||||
- Observations are fire-and-forget (curl errors ignored)
|
||||
|
||||
### Missing Data
|
||||
- Empty `conversation_id` → use `generation_id`
|
||||
- Empty `workspace_root` → use `pwd`
|
||||
- Missing tool data → skip observation
|
||||
|
||||
### Network Errors
|
||||
- All HTTP requests use `curl -s` (silent)
|
||||
- Errors redirected to `/dev/null`
|
||||
- Hooks always exit 0 to avoid blocking Cursor
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Context Injection**: Cursor's `beforeSubmitPrompt` doesn't support prompt modification. Context must be retrieved via:
|
||||
- MCP tools (claude-mem provides search tools)
|
||||
- Manual retrieval from web viewer
|
||||
- Future: Agent SDK integration
|
||||
|
||||
2. **Transcript Access**: Cursor hooks don't provide transcript paths, limiting summary quality compared to Claude Code integration.
|
||||
|
||||
3. **Session Model**: Uses `conversation_id` which may not perfectly match Claude Code's session model.
|
||||
|
||||
4. **Tab Hooks**: Currently only supports Agent hooks. Tab (inline completion) hooks could be added separately.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Enhanced context injection via MCP tools
|
||||
- [ ] Support for `beforeTabFileRead` and `afterTabFileEdit` hooks
|
||||
- [ ] Better error reporting and logging
|
||||
- [ ] Integration with Cursor's agent SDK
|
||||
- [ ] Support for blocking/approval workflows
|
||||
- [ ] Real-time context injection via agent messages
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Test session initialization**:
|
||||
```bash
|
||||
echo '{"conversation_id":"test-123","workspace_roots":["/tmp/test"],"prompt":"test"}' | \
|
||||
~/.cursor/hooks/session-init.sh
|
||||
```
|
||||
|
||||
2. **Test observation capture**:
|
||||
```bash
|
||||
echo '{"conversation_id":"test-123","hook_event_name":"afterMCPExecution","tool_name":"test","tool_input":{},"result_json":{}}' | \
|
||||
~/.cursor/hooks/save-observation.sh
|
||||
```
|
||||
|
||||
3. **Test context retrieval**:
|
||||
```bash
|
||||
curl "http://127.0.0.1:37777/api/context/inject?project=test"
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
1. Enable hooks in Cursor
|
||||
2. Submit a prompt
|
||||
3. Execute some tools
|
||||
4. Check web viewer: `http://localhost:37777`
|
||||
5. Verify observations appear in database
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
See [README.md](README.md#troubleshooting) for detailed troubleshooting steps.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Feature Parity: Claude-Mem Hooks vs Cursor Hooks
|
||||
|
||||
This document compares claude-mem's Claude Code hooks with the Cursor hooks implementation to ensure feature parity.
|
||||
|
||||
## Hook Mapping
|
||||
|
||||
| Claude Code Hook | Cursor Hook | Status | Notes |
|
||||
|-----------------|-------------|--------|-------|
|
||||
| `SessionStart` → `context-hook.js` | `beforeSubmitPrompt` → `context-inject.sh` | ✅ Partial | Context fetched but not injectable in Cursor |
|
||||
| `SessionStart` → `user-message-hook.js` | (Optional) `user-message.sh` | ⚠️ Optional | No SessionStart equivalent; can run on beforeSubmitPrompt |
|
||||
| `UserPromptSubmit` → `new-hook.js` | `beforeSubmitPrompt` → `session-init.sh` | ✅ Complete | Session init, privacy checks, slash stripping |
|
||||
| `PostToolUse` → `save-hook.js` | `afterMCPExecution` + `afterShellExecution` → `save-observation.sh` | ✅ Complete | Tool observation capture |
|
||||
| `PostToolUse` → (file edits) | `afterFileEdit` → `save-file-edit.sh` | ✅ Complete | File edit observation capture |
|
||||
| `Stop` → `summary-hook.js` | `stop` → `session-summary.sh` | ⚠️ Partial | Summary generation (no transcript access) |
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
### 1. Session Initialization (`new-hook.js` ↔ `session-init.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| Worker health check | ✅ 75 retries (15s) | ✅ 75 retries (15s) | ✅ Match |
|
||||
| Session init API call | ✅ `/api/sessions/init` | ✅ `/api/sessions/init` | ✅ Match |
|
||||
| Privacy check handling | ✅ Checks `skipped` + `reason` | ✅ Checks `skipped` + `reason` | ✅ Match |
|
||||
| Slash stripping | ✅ Strips leading `/` | ✅ Strips leading `/` | ✅ Match |
|
||||
| SDK agent init | ✅ `/sessions/{id}/init` | ❌ Not needed | ✅ N/A (Cursor-specific) |
|
||||
|
||||
**Status**: ✅ Complete parity (SDK agent init not applicable to Cursor)
|
||||
|
||||
### 2. Context Injection (`context-hook.js` ↔ `context-inject.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| Worker health check | ✅ 75 retries | ✅ 75 retries | ✅ Match |
|
||||
| Context fetch | ✅ `/api/context/inject` | ✅ `/api/context/inject` | ✅ Match |
|
||||
| Output format | ✅ JSON with `hookSpecificOutput` | ✅ Write to `.cursor/rules/` file | ✅ Alternative |
|
||||
| Project name extraction | ✅ `getProjectName(cwd)` | ✅ `basename(workspace_root)` | ✅ Match |
|
||||
| Auto-refresh | ✅ Each session start | ✅ Each prompt submission | ✅ Enhanced |
|
||||
|
||||
**Status**: ✅ Complete parity via auto-updated rules file
|
||||
|
||||
**How it works**:
|
||||
- Hook writes context to `.cursor/rules/claude-mem-context.mdc`
|
||||
- File has `alwaysApply: true` frontmatter
|
||||
- Cursor auto-includes this rule in all chat sessions
|
||||
- Context refreshes on every prompt submission
|
||||
|
||||
### 3. User Message Display (`user-message-hook.js` ↔ `user-message.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| Context fetch with colors | ✅ `/api/context/inject?colors=true` | ✅ `/api/context/inject?colors=true` | ✅ Match |
|
||||
| Output channel | ✅ stderr | ✅ stderr | ✅ Match |
|
||||
| Display format | ✅ Formatted with emojis | ✅ Formatted with emojis | ✅ Match |
|
||||
| Hook trigger | ✅ SessionStart | ⚠️ Optional (no SessionStart) | ⚠️ Cursor limitation |
|
||||
|
||||
**Status**: ⚠️ Optional (no SessionStart equivalent in Cursor)
|
||||
|
||||
**Note**: Can be added to `beforeSubmitPrompt` if desired, but may be verbose.
|
||||
|
||||
### 4. Observation Capture (`save-hook.js` ↔ `save-observation.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| Worker health check | ✅ 75 retries | ✅ 75 retries | ✅ Match |
|
||||
| Tool name extraction | ✅ From `tool_name` | ✅ From `tool_name` or "Bash" | ✅ Match |
|
||||
| Tool input capture | ✅ Full JSON | ✅ Full JSON | ✅ Match |
|
||||
| Tool response capture | ✅ Full JSON | ✅ Full JSON or output | ✅ Match |
|
||||
| Privacy tag stripping | ✅ Worker handles | ✅ Worker handles | ✅ Match |
|
||||
| Error handling | ✅ Fire-and-forget | ✅ Fire-and-forget | ✅ Match |
|
||||
| Shell command mapping | ✅ N/A (separate hook) | ✅ Maps to "Bash" tool | ✅ Enhanced |
|
||||
|
||||
**Status**: ✅ Complete parity (enhanced with shell command support)
|
||||
|
||||
### 5. File Edit Capture (N/A ↔ `save-file-edit.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| File path extraction | N/A | ✅ From `file_path` | ✅ New |
|
||||
| Edit details | N/A | ✅ From `edits` array | ✅ New |
|
||||
| Tool name | N/A | ✅ "write_file" | ✅ New |
|
||||
| Edit summary | N/A | ✅ Generated from edits | ✅ New |
|
||||
|
||||
**Status**: ✅ New feature (Cursor-specific, not in Claude Code)
|
||||
|
||||
### 6. Session Summary (`summary-hook.js` ↔ `session-summary.sh`)
|
||||
|
||||
| Feature | Claude Code | Cursor | Status |
|
||||
|---------|-------------|--------|--------|
|
||||
| Worker health check | ✅ 75 retries | ✅ 75 retries | ✅ Match |
|
||||
| Transcript parsing | ✅ Extracts last messages | ❌ No transcript access | ⚠️ Cursor limitation |
|
||||
| Summary API call | ✅ `/api/sessions/summarize` | ✅ `/api/sessions/summarize` | ✅ Match |
|
||||
| Last message extraction | ✅ From transcript | ❌ Empty strings | ⚠️ Cursor limitation |
|
||||
| Error handling | ✅ Fire-and-forget | ✅ Fire-and-forget | ✅ Match |
|
||||
|
||||
**Status**: ⚠️ Partial parity (no transcript access in Cursor)
|
||||
|
||||
**Note**: Summary generation still works but may be less accurate without last messages. Worker generates summary from observations stored during session.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Worker Health Checks
|
||||
- **Claude Code**: 75 retries × 200ms = 15 seconds
|
||||
- **Cursor**: 75 retries × 200ms = 15 seconds
|
||||
- **Status**: ✅ Match
|
||||
|
||||
### Error Handling
|
||||
- **Claude Code**: Fire-and-forget with logging
|
||||
- **Cursor**: Fire-and-forget with graceful exit (exit 0)
|
||||
- **Status**: ✅ Match (adapted for Cursor's hook system)
|
||||
|
||||
### Privacy Handling
|
||||
- **Claude Code**: Worker performs privacy checks, hooks respect `skipped` flag
|
||||
- **Cursor**: Worker performs privacy checks, hooks respect `skipped` flag
|
||||
- **Status**: ✅ Match
|
||||
|
||||
### Tag Stripping
|
||||
- **Claude Code**: Worker handles `<private>` and `<claude-mem-context>` tags
|
||||
- **Cursor**: Worker handles tags (hooks don't need to strip)
|
||||
- **Status**: ✅ Match
|
||||
|
||||
## Missing Features (Cursor Limitations)
|
||||
|
||||
1. ~~**Direct Context Injection**~~: **SOLVED** via auto-updated rules file
|
||||
- Hook writes context to `.cursor/rules/claude-mem-context.mdc`
|
||||
- Cursor auto-includes rules with `alwaysApply: true`
|
||||
- Context refreshes on every prompt
|
||||
|
||||
2. **Transcript Access**: Cursor hooks don't provide transcript paths
|
||||
- **Impact**: Summary generation less accurate
|
||||
- **Workaround**: Worker generates from observations
|
||||
|
||||
3. **SessionStart Hook**: Cursor doesn't have session start event
|
||||
- **Impact**: User message display must be optional
|
||||
- **Workaround**: Can run on `beforeSubmitPrompt` if desired
|
||||
|
||||
4. **SDK Agent Session**: Cursor doesn't use SDK agent pattern
|
||||
- **Impact**: No `/sessions/{id}/init` call needed
|
||||
- **Status**: ✅ Not applicable (Cursor-specific)
|
||||
|
||||
## Enhancements (Cursor-Specific)
|
||||
|
||||
1. **Shell Command Capture**: Maps shell commands to "Bash" tool observations
|
||||
- **Status**: ✅ Enhanced beyond Claude Code
|
||||
|
||||
2. **File Edit Capture**: Dedicated hook for file edits
|
||||
- **Status**: ✅ New feature
|
||||
|
||||
3. **MCP Tool Capture**: Captures MCP tool usage separately
|
||||
- **Status**: ✅ Enhanced beyond Claude Code
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| Core Functionality | ✅ Complete parity |
|
||||
| Session Management | ✅ Complete parity |
|
||||
| Observation Capture | ✅ Complete parity (enhanced) |
|
||||
| Context Injection | ✅ Complete parity (via rules file) |
|
||||
| Summary Generation | ⚠️ Partial (no transcript) |
|
||||
| User Experience | ⚠️ Partial (no SessionStart) |
|
||||
|
||||
**Overall**: The Cursor hooks implementation achieves **full functional parity** with claude-mem's Claude Code hooks:
|
||||
- ✅ Session initialization
|
||||
- ✅ Context injection (via auto-updated `.cursor/rules/` file)
|
||||
- ✅ Observation capture (MCP tools, shell commands, file edits)
|
||||
- ⚠️ Summary generation (works, but no transcript access)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Quick Start: Claude-Mem + Cursor Integration
|
||||
|
||||
> **Give your Cursor AI persistent memory in under 5 minutes**
|
||||
|
||||
## What This Does
|
||||
|
||||
Connects claude-mem to Cursor so that:
|
||||
- **Agent actions** (MCP tools, shell commands, file edits) are automatically saved
|
||||
- **Context from past sessions** is automatically injected via `.cursor/rules/`
|
||||
- **Sessions are summarized** for future reference
|
||||
|
||||
Your AI stops forgetting. It remembers the patterns, decisions, and context from previous sessions.
|
||||
|
||||
## Don't Have Claude Code?
|
||||
|
||||
If you're using Cursor without Claude Code, see [STANDALONE-SETUP.md](STANDALONE-SETUP.md) for setup with free-tier providers like Gemini or OpenRouter.
|
||||
|
||||
---
|
||||
|
||||
## Installation (1 minute)
|
||||
|
||||
```bash
|
||||
# Install globally for all projects (recommended)
|
||||
claude-mem cursor install user
|
||||
|
||||
# Or install for current project only
|
||||
claude-mem cursor install
|
||||
|
||||
# Check installation status
|
||||
claude-mem cursor status
|
||||
```
|
||||
|
||||
## Configure Provider (Required for Standalone)
|
||||
|
||||
If you don't have Claude Code, configure a provider for AI summarization:
|
||||
|
||||
```bash
|
||||
# Option A: Gemini (free tier available - recommended)
|
||||
claude-mem settings set CLAUDE_MEM_PROVIDER gemini
|
||||
claude-mem settings set CLAUDE_MEM_GEMINI_API_KEY your-api-key
|
||||
|
||||
# Option B: OpenRouter (free models available)
|
||||
claude-mem settings set CLAUDE_MEM_PROVIDER openrouter
|
||||
claude-mem settings set CLAUDE_MEM_OPENROUTER_API_KEY your-api-key
|
||||
```
|
||||
|
||||
**Get free API keys**:
|
||||
- Gemini: https://aistudio.google.com/apikey
|
||||
- OpenRouter: https://openrouter.ai/keys
|
||||
|
||||
## Start Worker
|
||||
|
||||
```bash
|
||||
claude-mem start
|
||||
|
||||
# Verify it's running
|
||||
claude-mem status
|
||||
```
|
||||
|
||||
## Restart Cursor
|
||||
|
||||
Restart Cursor to load the hooks.
|
||||
|
||||
## Verify It's Working
|
||||
|
||||
1. Open Cursor Settings → Hooks tab
|
||||
2. You should see the hooks listed
|
||||
3. Submit a prompt in Cursor
|
||||
4. Check the web viewer: http://localhost:37777
|
||||
5. You should see observations appearing
|
||||
|
||||
## What Gets Captured
|
||||
|
||||
- **MCP Tool Usage**: All MCP tool executions
|
||||
- **Shell Commands**: All terminal commands
|
||||
- **File Edits**: All file modifications
|
||||
- **Sessions**: Each conversation is tracked
|
||||
|
||||
## Accessing Memory
|
||||
|
||||
### Via Web Viewer
|
||||
- Open http://localhost:37777
|
||||
- Browse sessions, observations, and summaries
|
||||
- Search your project history
|
||||
|
||||
### Via MCP Tools (if enabled)
|
||||
- claude-mem provides search tools via MCP
|
||||
- Use `search`, `timeline`, and `get_observations` tools
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Hooks not running?**
|
||||
- Check Cursor Settings → Hooks tab for errors
|
||||
- Verify scripts are executable: `chmod +x ~/.cursor/hooks/*.sh`
|
||||
- Check Hooks output channel in Cursor
|
||||
|
||||
**Worker not responding?**
|
||||
- Check if worker is running: `curl http://127.0.0.1:37777/api/readiness`
|
||||
- Check logs: `tail -f ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log`
|
||||
- Restart worker: `bun run worker:restart`
|
||||
|
||||
**Observations not saving?**
|
||||
- Check worker logs for errors
|
||||
- Verify session was initialized in web viewer
|
||||
- Test API directly: `curl -X POST http://127.0.0.1:37777/api/sessions/observations ...`
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [README.md](README.md) for detailed documentation
|
||||
- Read [INTEGRATION.md](INTEGRATION.md) for architecture details
|
||||
- Visit [claude-mem docs](https://docs.claude-mem.ai) for full feature set
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
# Claude-Mem Cursor Hooks Integration
|
||||
|
||||
> **Persistent AI Memory for Cursor - Free Options Available**
|
||||
|
||||
Give your Cursor AI persistent memory across sessions. Your agent remembers what it worked on, the decisions it made, and the patterns in your codebase - automatically.
|
||||
|
||||
### Why Claude-Mem?
|
||||
|
||||
- **Remember context across sessions**: No more re-explaining your codebase every time
|
||||
- **Automatic capture**: MCP tools, shell commands, and file edits are logged without effort
|
||||
- **Free tier options**: Works with Gemini (1500 free req/day) or OpenRouter (free models available)
|
||||
- **Works with or without Claude Code**: Full functionality either way
|
||||
|
||||
### Quick Install (5 minutes)
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem && bun install && bun run build
|
||||
|
||||
# Interactive setup (configures provider + installs hooks)
|
||||
bun run cursor:setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start for Cursor Users
|
||||
|
||||
**Using Claude Code?** Skip to [Installation](#installation) - everything works automatically.
|
||||
|
||||
**Cursor-only (no Claude Code)?** See [STANDALONE-SETUP.md](STANDALONE-SETUP.md) for free-tier options using Gemini or OpenRouter.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The hooks bridge Cursor's hook system to claude-mem's worker API, allowing:
|
||||
- **Session Management**: Initialize sessions and generate summaries
|
||||
- **Observation Capture**: Record MCP tool usage, shell commands, and file edits
|
||||
- **Worker Readiness**: Ensure the worker is running before prompt submission
|
||||
|
||||
## Context Injection
|
||||
|
||||
Context is automatically injected via Cursor's **Rules** system:
|
||||
|
||||
1. **Install**: `claude-mem cursor install` generates initial context
|
||||
2. **Stop hook**: Updates context in `.cursor/rules/claude-mem-context.mdc` after each session
|
||||
3. **Cursor**: Automatically includes this rule in ALL chat sessions
|
||||
|
||||
**The context updates after each session ends**, so the next session sees fresh context.
|
||||
|
||||
### Additional Access Methods
|
||||
|
||||
- **MCP Tools**: Configure claude-mem's MCP server for `search`, `timeline`, `get_observations` tools
|
||||
- **Web Viewer**: Access context at `http://localhost:37777`
|
||||
- **Manual Request**: Ask the agent to search memory
|
||||
|
||||
See [CONTEXT-INJECTION.md](CONTEXT-INJECTION.md) for details.
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
# Install globally for all projects (recommended)
|
||||
claude-mem cursor install user
|
||||
|
||||
# Or install for current project only
|
||||
claude-mem cursor install
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
<details>
|
||||
<summary>Click to expand manual installation steps</summary>
|
||||
|
||||
**User-level** (recommended - applies to all projects):
|
||||
```bash
|
||||
# Copy hooks.json to your home directory
|
||||
cp cursor-hooks/hooks.json ~/.cursor/hooks.json
|
||||
|
||||
# Copy hook scripts
|
||||
mkdir -p ~/.cursor/hooks
|
||||
cp cursor-hooks/*.sh ~/.cursor/hooks/
|
||||
chmod +x ~/.cursor/hooks/*.sh
|
||||
```
|
||||
|
||||
**Project-level** (for per-project hooks):
|
||||
```bash
|
||||
# Copy hooks.json to your project
|
||||
mkdir -p .cursor
|
||||
cp cursor-hooks/hooks.json .cursor/hooks.json
|
||||
|
||||
# Copy hook scripts to your project
|
||||
mkdir -p .cursor/hooks
|
||||
cp cursor-hooks/*.sh .cursor/hooks/
|
||||
chmod +x .cursor/hooks/*.sh
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### After Installation
|
||||
|
||||
1. **Start the worker**:
|
||||
```bash
|
||||
claude-mem start
|
||||
```
|
||||
|
||||
2. **Restart Cursor** to load the hooks
|
||||
|
||||
3. **Verify installation**:
|
||||
```bash
|
||||
claude-mem cursor status
|
||||
```
|
||||
|
||||
## Hook Mappings
|
||||
|
||||
| Cursor Hook | Script | Purpose |
|
||||
|-------------|--------|---------|
|
||||
| `beforeSubmitPrompt` | `session-init.sh` | Initialize claude-mem session |
|
||||
| `beforeSubmitPrompt` | `context-inject.sh` | Ensure worker is running |
|
||||
| `afterMCPExecution` | `save-observation.sh` | Capture MCP tool usage |
|
||||
| `afterShellExecution` | `save-observation.sh` | Capture shell command execution |
|
||||
| `afterFileEdit` | `save-file-edit.sh` | Capture file edits |
|
||||
| `stop` | `session-summary.sh` | Generate summary + update context file |
|
||||
|
||||
## How It Works
|
||||
|
||||
### Session Initialization (`session-init.sh`)
|
||||
- Called before each prompt submission
|
||||
- Initializes a new session in claude-mem using `conversation_id` as the session ID
|
||||
- Extracts project name from workspace root
|
||||
- Outputs `{"continue": true}` to allow prompt submission
|
||||
|
||||
### Context Hook (`context-inject.sh`)
|
||||
- Ensures claude-mem worker is running before session
|
||||
- Outputs `{"continue": true}` to allow prompt submission
|
||||
- Note: Context file is updated by `session-summary.sh` (stop hook), not here
|
||||
|
||||
### Observation Capture (`save-observation.sh`)
|
||||
- Captures MCP tool executions and shell commands
|
||||
- Maps them to claude-mem's observation format
|
||||
- Sends to `/api/sessions/observations` endpoint (fire-and-forget)
|
||||
|
||||
### File Edit Capture (`save-file-edit.sh`)
|
||||
- Captures file edits made by the agent
|
||||
- Treats edits as "write_file" tool usage
|
||||
- Includes edit summaries in observations
|
||||
|
||||
### Session Summary (`session-summary.sh`)
|
||||
- Called when agent loop ends (stop hook)
|
||||
- Requests summary generation from claude-mem
|
||||
- **Updates context file** in `.cursor/rules/claude-mem-context.mdc` for next session
|
||||
|
||||
## Configuration
|
||||
|
||||
The hooks read configuration from `~/.claude-mem/settings.json`:
|
||||
|
||||
- `CLAUDE_MEM_WORKER_PORT`: Worker port (default: 37777)
|
||||
- `CLAUDE_MEM_WORKER_HOST`: Worker host (default: 127.0.0.1)
|
||||
|
||||
## Dependencies
|
||||
|
||||
The hook scripts require:
|
||||
- `jq` - JSON processing
|
||||
- `curl` - HTTP requests
|
||||
- `bash` - Shell interpreter
|
||||
|
||||
Install on macOS: `brew install jq curl`
|
||||
Install on Ubuntu: `apt-get install jq curl`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hooks not executing
|
||||
|
||||
1. Check hooks are in the correct location:
|
||||
```bash
|
||||
ls .cursor/hooks.json # Project-level
|
||||
ls ~/.cursor/hooks.json # User-level
|
||||
```
|
||||
|
||||
2. Verify scripts are executable:
|
||||
```bash
|
||||
chmod +x ~/.cursor/hooks/*.sh
|
||||
```
|
||||
|
||||
3. Check Cursor Settings → Hooks tab for configuration status
|
||||
|
||||
4. Check Hooks output channel in Cursor for error messages
|
||||
|
||||
### Worker not responding
|
||||
|
||||
1. Verify worker is running:
|
||||
```bash
|
||||
curl http://127.0.0.1:37777/api/readiness
|
||||
```
|
||||
|
||||
2. Check worker logs:
|
||||
```bash
|
||||
tail -f ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log
|
||||
```
|
||||
|
||||
3. Restart worker:
|
||||
```bash
|
||||
claude-mem restart
|
||||
```
|
||||
|
||||
### Observations not being saved
|
||||
|
||||
1. Monitor worker logs for incoming requests
|
||||
|
||||
2. Verify session was initialized via web viewer at `http://localhost:37777`
|
||||
|
||||
3. Test observation endpoint directly:
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:37777/api/sessions/observations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"contentSessionId":"test","tool_name":"test","tool_input":{},"tool_response":{},"cwd":"/tmp"}'
|
||||
```
|
||||
|
||||
## Comparison with Claude Code Integration
|
||||
|
||||
| Feature | Claude Code | Cursor |
|
||||
|---------|-------------|--------|
|
||||
| Session Initialization | ✅ `SessionStart` hook | ✅ `beforeSubmitPrompt` hook |
|
||||
| Context Injection | ✅ `additionalContext` field | ✅ Auto-updated `.cursor/rules/` file |
|
||||
| Observation Capture | ✅ `PostToolUse` hook | ✅ `afterMCPExecution`, `afterShellExecution`, `afterFileEdit` |
|
||||
| Session Summary | ✅ `Stop` hook with transcript | ⚠️ `stop` hook (no transcript) |
|
||||
| MCP Search Tools | ✅ Full support | ✅ Full support (if MCP configured) |
|
||||
|
||||
## Files
|
||||
|
||||
- `hooks.json` - Hook configuration
|
||||
- `common.sh` - Shared utility functions
|
||||
- `session-init.sh` - Session initialization
|
||||
- `context-inject.sh` - Context/worker readiness hook
|
||||
- `save-observation.sh` - MCP and shell observation capture
|
||||
- `save-file-edit.sh` - File edit observation capture
|
||||
- `session-summary.sh` - Summary generation
|
||||
- `cursorrules-template.md` - Template for `.cursorrules` file
|
||||
|
||||
## See Also
|
||||
|
||||
- [Claude-Mem Documentation](https://docs.claude-mem.ai)
|
||||
- [Cursor Hooks Reference](../docs/context/cursor-hooks-reference.md)
|
||||
- [Claude-Mem Architecture](https://docs.claude-mem.ai/architecture/overview)
|
||||
@@ -0,0 +1,327 @@
|
||||
# Comprehensive Review: Cursor Hooks Integration
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a thorough review of the Cursor hooks integration, covering all aspects from implementation details to edge cases and potential issues.
|
||||
|
||||
## Architecture Review
|
||||
|
||||
### ✅ Strengths
|
||||
|
||||
1. **Modular Design**: Common utilities extracted to `common.sh` for reusability
|
||||
2. **Error Handling**: Graceful degradation - hooks never block Cursor even on failures
|
||||
3. **Parity with Claude Code**: Matches claude-mem's hook behavior where possible
|
||||
4. **Fire-and-Forget**: Observations sent asynchronously, don't block agent execution
|
||||
|
||||
### ⚠️ Limitations (Platform-Specific)
|
||||
|
||||
1. **No Windows Support**: Bash scripts require Unix-like environment
|
||||
- **Mitigation**: Could add PowerShell equivalents or use Node.js/Python wrappers
|
||||
2. **Dependency on jq/curl**: Requires external tools
|
||||
- **Mitigation**: Dependency checks added, graceful fallback
|
||||
|
||||
## Script-by-Script Review
|
||||
|
||||
### 1. `common.sh` - Utility Functions
|
||||
|
||||
**Purpose**: Shared utilities for all hook scripts
|
||||
|
||||
**Functions**:
|
||||
- ✅ `check_dependencies()` - Validates jq and curl exist
|
||||
- ✅ `read_json_input()` - Safely reads and validates JSON from stdin
|
||||
- ✅ `get_worker_port()` - Reads port from settings with validation
|
||||
- ✅ `ensure_worker_running()` - Health checks with retries
|
||||
- ✅ `url_encode()` - URL encoding for special characters
|
||||
- ✅ `get_project_name()` - Extracts project name with edge case handling
|
||||
- ✅ `json_get()` - Safe JSON field extraction with array support
|
||||
- ✅ `is_empty()` - Null/empty string detection
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty stdin
|
||||
- ✅ Malformed JSON
|
||||
- ✅ Missing settings file
|
||||
- ✅ Invalid port numbers
|
||||
- ✅ Windows drive roots (C:\, etc.)
|
||||
- ✅ Empty workspace roots
|
||||
- ✅ Array field access (`workspace_roots[0]`)
|
||||
|
||||
**Potential Issues**:
|
||||
- ⚠️ `url_encode()` uses jq - if jq fails, encoding fails silently
|
||||
- ✅ **Fixed**: Falls back to original string if encoding fails
|
||||
|
||||
### 2. `session-init.sh` - Session Initialization
|
||||
|
||||
**Purpose**: Initialize claude-mem session when prompt is submitted
|
||||
|
||||
**Flow**:
|
||||
1. Read and validate JSON input
|
||||
2. Extract session_id, project, prompt
|
||||
3. Ensure worker is running
|
||||
4. Strip leading slash from prompt (parity with new-hook.ts)
|
||||
5. Call `/api/sessions/init`
|
||||
6. Handle privacy checks
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty conversation_id → fallback to generation_id
|
||||
- ✅ Empty workspace_root → fallback to pwd
|
||||
- ✅ Empty prompt → still initializes session
|
||||
- ✅ Worker unavailable → graceful exit
|
||||
- ✅ Privacy-skipped sessions → silent exit
|
||||
- ✅ Invalid JSON → graceful exit
|
||||
|
||||
**Potential Issues**:
|
||||
- ✅ **Fixed**: String slicing now checks for empty strings
|
||||
- ✅ **Fixed**: All jq operations have error handling
|
||||
- ✅ **Fixed**: Worker health check with proper retries
|
||||
|
||||
**Parity with Claude Code**:
|
||||
- ✅ Session initialization
|
||||
- ✅ Privacy check handling
|
||||
- ✅ Slash stripping
|
||||
- ❌ SDK agent init (not applicable to Cursor)
|
||||
|
||||
### 3. `save-observation.sh` - Observation Capture
|
||||
|
||||
**Purpose**: Capture MCP tool usage and shell commands
|
||||
|
||||
**Flow**:
|
||||
1. Read and validate JSON input
|
||||
2. Determine hook type (MCP vs Shell)
|
||||
3. Extract tool data
|
||||
4. Validate JSON structures
|
||||
5. Ensure worker is running
|
||||
6. Send observation (fire-and-forget)
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty tool_name → exit gracefully
|
||||
- ✅ Invalid tool_input/tool_response → default to {}
|
||||
- ✅ Malformed JSON in tool data → validated and sanitized
|
||||
- ✅ Empty session_id → exit gracefully
|
||||
- ✅ Worker unavailable → exit gracefully
|
||||
|
||||
**Potential Issues**:
|
||||
- ✅ **Fixed**: JSON validation for tool_input and tool_response
|
||||
- ✅ **Fixed**: Proper handling of empty/null values
|
||||
- ✅ **Fixed**: Error handling for all jq operations
|
||||
|
||||
**Parity with Claude Code**:
|
||||
- ✅ Tool observation capture
|
||||
- ✅ Privacy tag stripping (handled by worker)
|
||||
- ✅ Fire-and-forget pattern
|
||||
- ✅ Enhanced: Shell command capture (not in Claude Code)
|
||||
|
||||
### 4. `save-file-edit.sh` - File Edit Capture
|
||||
|
||||
**Purpose**: Capture file edits as observations
|
||||
|
||||
**Flow**:
|
||||
1. Read and validate JSON input
|
||||
2. Extract file_path and edits array
|
||||
3. Validate edits array
|
||||
4. Create edit summary
|
||||
5. Ensure worker is running
|
||||
6. Send observation (fire-and-forget)
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty file_path → exit gracefully
|
||||
- ✅ Empty edits array → exit gracefully
|
||||
- ✅ Invalid edits JSON → default to []
|
||||
- ✅ Malformed edit objects → summary generation handles gracefully
|
||||
- ✅ Empty session_id → exit gracefully
|
||||
|
||||
**Potential Issues**:
|
||||
- ✅ **Fixed**: Edit summary generation with error handling
|
||||
- ✅ **Fixed**: Array validation before processing
|
||||
- ✅ **Fixed**: Safe string slicing in summary generation
|
||||
|
||||
**Parity with Claude Code**:
|
||||
- ✅ File edit capture (new feature for Cursor)
|
||||
- ✅ Observation format matches claude-mem structure
|
||||
|
||||
### 5. `session-summary.sh` - Summary Generation
|
||||
|
||||
**Purpose**: Generate session summary when agent loop ends
|
||||
|
||||
**Flow**:
|
||||
1. Read and validate JSON input
|
||||
2. Extract session_id
|
||||
3. Ensure worker is running
|
||||
4. Send summarize request with empty messages (no transcript access)
|
||||
5. Output empty JSON (required by Cursor)
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty session_id → exit gracefully
|
||||
- ✅ Worker unavailable → exit gracefully
|
||||
- ✅ Missing transcript → empty messages (worker handles gracefully)
|
||||
|
||||
**Potential Issues**:
|
||||
- ✅ **Fixed**: Proper JSON output for Cursor stop hook
|
||||
- ✅ **Fixed**: Worker handles empty messages (verified in codebase)
|
||||
|
||||
**Parity with Claude Code**:
|
||||
- ⚠️ Partial: No transcript access, so no last_user_message/last_assistant_message
|
||||
- ✅ Summary generation still works (based on observations)
|
||||
|
||||
### 6. `context-inject.sh` - Context Injection via Rules File
|
||||
|
||||
**Purpose**: Fetch context and write to `.cursor/rules/` for auto-injection
|
||||
|
||||
**How It Works**:
|
||||
1. Fetches context from claude-mem worker
|
||||
2. Writes to `.cursor/rules/claude-mem-context.mdc` with `alwaysApply: true`
|
||||
3. Cursor auto-includes this rule in all chat sessions
|
||||
4. Context refreshes on every prompt submission
|
||||
|
||||
**Flow**:
|
||||
1. Read and validate JSON input
|
||||
2. Extract workspace root
|
||||
3. Get project name
|
||||
4. Ensure worker is running
|
||||
5. Fetch context from `/api/context/inject`
|
||||
6. Write context to `.cursor/rules/claude-mem-context.mdc`
|
||||
7. Output `{"continue": true}`
|
||||
|
||||
**Edge Cases Handled**:
|
||||
- ✅ Empty workspace_root → fallback to pwd
|
||||
- ✅ Worker unavailable → allow prompt to continue
|
||||
- ✅ Context fetch failure → allow prompt to continue (no file written)
|
||||
- ✅ Special characters in project name → URL encoded
|
||||
- ✅ Missing `.cursor/rules/` directory → created automatically
|
||||
|
||||
**Parity with Claude Code**:
|
||||
- ✅ Context injection achieved via rules file workaround
|
||||
- ✅ Worker readiness check matches Claude Code
|
||||
- ✅ Context available immediately in next prompt
|
||||
|
||||
## Error Handling Review
|
||||
|
||||
### ✅ Comprehensive Error Handling
|
||||
|
||||
1. **Input Validation**:
|
||||
- ✅ Empty stdin → default to `{}`
|
||||
- ✅ Malformed JSON → validated and sanitized
|
||||
- ✅ Missing fields → safe fallbacks
|
||||
|
||||
2. **Dependency Checks**:
|
||||
- ✅ jq and curl existence checked
|
||||
- ✅ Non-blocking (warns but continues)
|
||||
|
||||
3. **Network Errors**:
|
||||
- ✅ Worker unavailable → graceful exit
|
||||
- ✅ HTTP failures → fire-and-forget (don't block)
|
||||
- ✅ Timeout handling → 15 second retries
|
||||
|
||||
4. **Data Validation**:
|
||||
- ✅ Port number validation (1-65535)
|
||||
- ✅ JSON structure validation
|
||||
- ✅ Empty/null value handling
|
||||
|
||||
## Security Review
|
||||
|
||||
### ✅ Security Considerations
|
||||
|
||||
1. **Input Sanitization**:
|
||||
- ✅ JSON validation prevents injection
|
||||
- ✅ URL encoding for special characters
|
||||
- ✅ Worker handles privacy tag stripping
|
||||
|
||||
2. **Error Information**:
|
||||
- ✅ Errors don't expose sensitive data
|
||||
- ✅ Fire-and-forget prevents information leakage
|
||||
|
||||
3. **Dependency Security**:
|
||||
- ✅ Uses standard tools (jq, curl)
|
||||
- ✅ No custom code execution
|
||||
|
||||
## Performance Review
|
||||
|
||||
### ✅ Performance Optimizations
|
||||
|
||||
1. **Non-Blocking**:
|
||||
- ✅ All hooks exit quickly (don't block Cursor)
|
||||
- ✅ Observations sent asynchronously
|
||||
|
||||
2. **Efficient Health Checks**:
|
||||
- ✅ 200ms polling interval
|
||||
- ✅ 15 second maximum wait
|
||||
- ✅ Early exit on success
|
||||
|
||||
3. **Resource Usage**:
|
||||
- ✅ Minimal memory footprint
|
||||
- ✅ No long-running processes
|
||||
- ✅ Fire-and-forget HTTP requests
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Unit Tests Needed
|
||||
|
||||
1. **common.sh functions**:
|
||||
- [ ] Test `json_get()` with various field types
|
||||
- [ ] Test `get_project_name()` with edge cases
|
||||
- [ ] Test `url_encode()` with special characters
|
||||
- [ ] Test `ensure_worker_running()` with various states
|
||||
|
||||
2. **Hook scripts**:
|
||||
- [ ] Test with empty input
|
||||
- [ ] Test with malformed JSON
|
||||
- [ ] Test with missing fields
|
||||
- [ ] Test with worker unavailable
|
||||
- [ ] Test with invalid port numbers
|
||||
|
||||
### Integration Tests Needed
|
||||
|
||||
1. **End-to-end flow**:
|
||||
- [ ] Session initialization → observation capture → summary
|
||||
- [ ] Multiple concurrent hooks
|
||||
- [ ] Worker restart scenarios
|
||||
|
||||
2. **Edge cases**:
|
||||
- [ ] Very long prompts/commands
|
||||
- [ ] Special characters in paths
|
||||
- [ ] Unicode in tool inputs
|
||||
- [ ] Large file edits
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Cursor Hook System**:
|
||||
- ✅ Context injection solved via `.cursor/rules/` file
|
||||
- ❌ No transcript access for summary generation
|
||||
- ❌ No SessionStart equivalent
|
||||
|
||||
2. **Platform Support**:
|
||||
- ⚠️ Bash scripts (Unix-like only)
|
||||
- ⚠️ Requires jq and curl
|
||||
|
||||
3. **Context Injection**:
|
||||
- ✅ Solved via auto-updated `.cursor/rules/claude-mem-context.mdc`
|
||||
- ✅ Context also available via MCP tools
|
||||
- ✅ Context also available via web viewer
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Improvements
|
||||
|
||||
1. ✅ **DONE**: Comprehensive error handling
|
||||
2. ✅ **DONE**: Input validation
|
||||
3. ✅ **DONE**: Dependency checks
|
||||
4. ✅ **DONE**: URL encoding
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
1. **Logging**: Add optional debug logging to help troubleshoot
|
||||
2. **Metrics**: Track hook execution times and success rates
|
||||
3. **Windows Support**: PowerShell or Node.js equivalents
|
||||
4. **Testing**: Automated test suite
|
||||
5. **Documentation**: More examples and troubleshooting guides
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Cursor hooks integration is **production-ready** with:
|
||||
- ✅ Comprehensive error handling
|
||||
- ✅ Input validation and sanitization
|
||||
- ✅ Graceful degradation
|
||||
- ✅ Feature parity with Claude Code hooks (where applicable)
|
||||
- ✅ Enhanced features (shell/file edit capture)
|
||||
|
||||
The implementation handles edge cases well and follows best practices for reliability and maintainability.
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Claude-Mem for Cursor (No Claude Code Required)
|
||||
|
||||
> **Persistent AI Memory for Cursor - Zero Cost to Start**
|
||||
|
||||
## Overview
|
||||
|
||||
Use claude-mem's persistent memory in Cursor without a Claude Code subscription. Choose between free-tier providers (Gemini, OpenRouter) or paid options.
|
||||
|
||||
**What You Get**:
|
||||
- **Persistent memory** that survives across sessions - your AI remembers what it worked on
|
||||
- **Automatic capture** of MCP tools, shell commands, and file edits
|
||||
- **Context injection** via `.cursor/rules/` - relevant history included in every chat
|
||||
- **Web viewer** at http://localhost:37777 - browse and search your project history
|
||||
|
||||
**Why This Matters**: Every Cursor session starts fresh. Claude-mem bridges that gap - your AI agent builds cumulative knowledge about your codebase, decisions, and patterns over time.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### macOS / Linux
|
||||
- Cursor IDE
|
||||
- [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`)
|
||||
- Git
|
||||
- `jq` and `curl`:
|
||||
- **macOS**: `brew install jq curl`
|
||||
- **Linux**: `apt install jq curl`
|
||||
|
||||
### Windows
|
||||
- Cursor IDE
|
||||
- [Bun](https://bun.sh) (PowerShell: `powershell -c "irm bun.sh/install.ps1 | iex"`)
|
||||
- Git
|
||||
- PowerShell 5.1+ (included with Windows 10/11)
|
||||
|
||||
## Step 1: Clone Claude-Mem
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build the project
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Step 2: Configure Provider (Choose One)
|
||||
|
||||
Since you don't have Claude Code, you need to configure an AI provider for claude-mem's summarization engine.
|
||||
|
||||
### Option A: Gemini (Recommended - Free Tier)
|
||||
|
||||
Gemini offers 1500 free requests per day, plenty for typical usage.
|
||||
|
||||
```bash
|
||||
# Create settings directory
|
||||
mkdir -p ~/.claude-mem
|
||||
|
||||
# Create settings file
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_GEMINI_API_KEY",
|
||||
"CLAUDE_MEM_GEMINI_MODEL": "gemini-2.5-flash-lite",
|
||||
"CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED": true
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Get your free API key**: https://aistudio.google.com/apikey
|
||||
|
||||
### Option B: OpenRouter (100+ Models)
|
||||
|
||||
OpenRouter provides access to many models, including free options.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude-mem
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "YOUR_OPENROUTER_API_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Get your API key**: https://openrouter.ai/keys
|
||||
|
||||
**Free models available**:
|
||||
- `google/gemini-2.0-flash-exp:free`
|
||||
- `xiaomi/mimo-v2-flash:free`
|
||||
|
||||
### Option C: Claude API (If You Have API Access)
|
||||
|
||||
If you have Anthropic API credits but not a Claude Code subscription:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude-mem
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "claude",
|
||||
"ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## Step 3: Install Cursor Hooks
|
||||
|
||||
```bash
|
||||
# From the claude-mem repo directory (recommended - all projects)
|
||||
bun run cursor:install -- user
|
||||
|
||||
# Or for project-level only:
|
||||
bun run cursor:install
|
||||
```
|
||||
|
||||
This installs:
|
||||
- Hook scripts to `.cursor/hooks/`
|
||||
- Hook configuration to `.cursor/hooks.json`
|
||||
- Context template to `.cursor/rules/`
|
||||
|
||||
## Step 4: Start the Worker
|
||||
|
||||
```bash
|
||||
bun run worker:start
|
||||
```
|
||||
|
||||
The worker runs in the background and handles:
|
||||
- Session management
|
||||
- Observation processing
|
||||
- AI-powered summarization
|
||||
- Context file updates
|
||||
|
||||
## Step 5: Restart Cursor & Verify
|
||||
|
||||
1. **Restart Cursor IDE** to load the new hooks
|
||||
|
||||
2. **Check installation status**:
|
||||
```bash
|
||||
bun run cursor:status
|
||||
```
|
||||
|
||||
3. **Verify the worker is running**:
|
||||
```bash
|
||||
curl http://127.0.0.1:37777/api/readiness
|
||||
```
|
||||
Should return: `{"status":"ready"}`
|
||||
|
||||
4. **Open the web viewer**: http://localhost:37777
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Before each prompt**: Hooks initialize a session and ensure the worker is running
|
||||
2. **During agent work**: MCP tools, shell commands, and file edits are captured
|
||||
3. **When agent stops**: Summary is generated and context file is updated
|
||||
4. **Next session**: Fresh context is automatically injected via `.cursor/rules/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No provider configured" error
|
||||
|
||||
Verify your settings file exists and has valid credentials:
|
||||
```bash
|
||||
cat ~/.claude-mem/settings.json
|
||||
```
|
||||
|
||||
### Worker not starting
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
tail -f ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log
|
||||
```
|
||||
|
||||
### Hooks not executing
|
||||
|
||||
1. Check Cursor Settings → Hooks tab for errors
|
||||
2. Verify scripts are executable:
|
||||
```bash
|
||||
chmod +x ~/.cursor/hooks/*.sh
|
||||
```
|
||||
3. Check the Hooks output channel in Cursor
|
||||
|
||||
### Rate limiting (Gemini free tier)
|
||||
|
||||
If you hit the 1500 requests/day limit:
|
||||
- Wait until the next day
|
||||
- Upgrade to a paid plan
|
||||
- Switch to OpenRouter with a paid model
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read [README.md](README.md) for detailed hook documentation
|
||||
- Check [CONTEXT-INJECTION.md](CONTEXT-INJECTION.md) for context behavior details
|
||||
- Visit https://docs.claude-mem.ai for full documentation
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `bun run cursor:install -- user` | Install hooks for all projects (recommended) |
|
||||
| `bun run cursor:install` | Install hooks for current project only |
|
||||
| `bun run cursor:status` | Check installation status |
|
||||
| `bun run worker:start` | Start the background worker |
|
||||
| `bun run worker:stop` | Stop the background worker |
|
||||
| `bun run worker:restart` | Restart the worker |
|
||||
|
||||
---
|
||||
|
||||
## Windows Installation
|
||||
|
||||
Windows users get full support via PowerShell scripts. The installer automatically detects Windows and installs the appropriate scripts.
|
||||
|
||||
### Enable Script Execution (if needed)
|
||||
|
||||
PowerShell may require you to enable script execution:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
### Step-by-Step for Windows
|
||||
|
||||
```powershell
|
||||
# Clone and build
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem
|
||||
bun install
|
||||
bun run build
|
||||
|
||||
# Configure provider (Gemini example)
|
||||
$settingsDir = "$env:USERPROFILE\.claude-mem"
|
||||
New-Item -ItemType Directory -Force -Path $settingsDir
|
||||
|
||||
@"
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_GEMINI_API_KEY"
|
||||
}
|
||||
"@ | Out-File -FilePath "$settingsDir\settings.json" -Encoding UTF8
|
||||
|
||||
# Interactive setup (recommended - walks you through everything)
|
||||
bun run cursor:setup
|
||||
|
||||
# Or manual installation
|
||||
bun run cursor:install
|
||||
bun run worker:start
|
||||
```
|
||||
|
||||
### What Gets Installed on Windows
|
||||
|
||||
The installer copies these PowerShell scripts to `.cursor\hooks\`:
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `common.ps1` | Shared utilities |
|
||||
| `session-init.ps1` | Initialize session on prompt |
|
||||
| `context-inject.ps1` | Inject memory context |
|
||||
| `save-observation.ps1` | Capture MCP/shell usage |
|
||||
| `save-file-edit.ps1` | Capture file edits |
|
||||
| `session-summary.ps1` | Generate summary on stop |
|
||||
|
||||
The `hooks.json` file is configured to invoke PowerShell with `-ExecutionPolicy Bypass` to ensure scripts run without additional configuration.
|
||||
|
||||
### Windows Troubleshooting
|
||||
|
||||
**"Execution of scripts is disabled on this system"**
|
||||
|
||||
Run as Administrator:
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine
|
||||
```
|
||||
|
||||
**PowerShell scripts not running**
|
||||
|
||||
Verify the hooks.json contains PowerShell invocations:
|
||||
```powershell
|
||||
Get-Content .cursor\hooks.json
|
||||
```
|
||||
|
||||
Should show commands like:
|
||||
```
|
||||
powershell.exe -ExecutionPolicy Bypass -File "./.cursor/hooks/session-init.ps1"
|
||||
```
|
||||
|
||||
**Worker not responding**
|
||||
|
||||
Check if port 37777 is in use:
|
||||
```powershell
|
||||
Get-NetTCPConnection -LocalPort 37777
|
||||
```
|
||||
|
||||
**Antivirus blocking scripts**
|
||||
|
||||
Some antivirus software may block PowerShell scripts. Add an exception for the `.cursor\hooks\` directory if needed.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Common utility functions for Cursor hooks (PowerShell)
|
||||
# Dot-source this file in hook scripts: . "$PSScriptRoot\common.ps1"
|
||||
# Note: ErrorActionPreference should be set in each script, not globally here
|
||||
|
||||
# Get worker port from settings with validation
|
||||
function Get-WorkerPort {
|
||||
$settingsPath = Join-Path $env:USERPROFILE ".claude-mem\settings.json"
|
||||
$port = 37777
|
||||
|
||||
if (Test-Path $settingsPath) {
|
||||
try {
|
||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
if ($settings.CLAUDE_MEM_WORKER_PORT) {
|
||||
$parsedPort = [int]$settings.CLAUDE_MEM_WORKER_PORT
|
||||
if ($parsedPort -ge 1 -and $parsedPort -le 65535) {
|
||||
$port = $parsedPort
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# Ignore parse errors, use default
|
||||
}
|
||||
}
|
||||
|
||||
return $port
|
||||
}
|
||||
|
||||
# Ensure worker is running with retries
|
||||
function Test-WorkerReady {
|
||||
param(
|
||||
[int]$Port = 37777,
|
||||
[int]$MaxRetries = 75
|
||||
)
|
||||
|
||||
for ($i = 0; $i -lt $MaxRetries; $i++) {
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/readiness" -Method Get -TimeoutSec 1 -ErrorAction Stop
|
||||
return $true
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Get project name from workspace root
|
||||
function Get-ProjectName {
|
||||
param([string]$WorkspaceRoot)
|
||||
|
||||
if ([string]::IsNullOrEmpty($WorkspaceRoot)) {
|
||||
return "unknown-project"
|
||||
}
|
||||
|
||||
# Handle Windows drive root (e.g., "C:\")
|
||||
if ($WorkspaceRoot -match '^([A-Za-z]):\\?$') {
|
||||
return "drive-$($Matches[1].ToUpper())"
|
||||
}
|
||||
|
||||
$projectName = Split-Path $WorkspaceRoot -Leaf
|
||||
if ([string]::IsNullOrEmpty($projectName)) {
|
||||
return "unknown-project"
|
||||
}
|
||||
|
||||
return $projectName
|
||||
}
|
||||
|
||||
# URL encode a string
|
||||
function Get-UrlEncodedString {
|
||||
param([string]$String)
|
||||
|
||||
if ([string]::IsNullOrEmpty($String)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return [System.Uri]::EscapeDataString($String)
|
||||
}
|
||||
|
||||
# Check if string is empty or null
|
||||
function Test-IsEmpty {
|
||||
param([string]$String)
|
||||
|
||||
return [string]::IsNullOrEmpty($String) -or $String -eq "null" -or $String -eq "empty"
|
||||
}
|
||||
|
||||
# Safely read JSON from stdin with error handling
|
||||
function Read-JsonInput {
|
||||
try {
|
||||
$input = [Console]::In.ReadToEnd()
|
||||
if ([string]::IsNullOrEmpty($input)) {
|
||||
return @{}
|
||||
}
|
||||
return $input | ConvertFrom-Json -ErrorAction Stop
|
||||
} catch {
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
# Safely get JSON field with fallback
|
||||
function Get-JsonField {
|
||||
param(
|
||||
[PSObject]$Json,
|
||||
[string]$Field,
|
||||
[string]$Fallback = ""
|
||||
)
|
||||
|
||||
if ($null -eq $Json) {
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
# Handle array access syntax (e.g., "workspace_roots[0]")
|
||||
if ($Field -match '^(.+)\[(\d+)\]$') {
|
||||
$arrayField = $Matches[1]
|
||||
$index = [int]$Matches[2]
|
||||
|
||||
if ($Json.PSObject.Properties.Name -contains $arrayField) {
|
||||
$array = $Json.$arrayField
|
||||
if ($null -ne $array -and $array.Count -gt $index) {
|
||||
$value = $array[$index]
|
||||
if (-not (Test-IsEmpty $value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
}
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
# Simple field access
|
||||
if ($Json.PSObject.Properties.Name -contains $Field) {
|
||||
$value = $Json.$Field
|
||||
if (-not (Test-IsEmpty $value)) {
|
||||
return $value
|
||||
}
|
||||
}
|
||||
|
||||
return $Fallback
|
||||
}
|
||||
|
||||
# Convert object to JSON string (compact)
|
||||
function ConvertTo-JsonCompact {
|
||||
param([object]$Object)
|
||||
|
||||
return $Object | ConvertTo-Json -Compress -Depth 10
|
||||
}
|
||||
|
||||
# Send HTTP POST request (fire-and-forget style)
|
||||
function Send-HttpPostAsync {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[object]$Body
|
||||
)
|
||||
|
||||
try {
|
||||
$bodyJson = ConvertTo-JsonCompact $Body
|
||||
Start-Job -ScriptBlock {
|
||||
param($u, $b)
|
||||
try {
|
||||
Invoke-RestMethod -Uri $u -Method Post -Body $b -ContentType "application/json" -TimeoutSec 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
} -ArgumentList $Uri, $bodyJson | Out-Null
|
||||
} catch {
|
||||
# Ignore errors - fire and forget
|
||||
}
|
||||
}
|
||||
|
||||
# Send HTTP POST request (synchronous)
|
||||
function Send-HttpPost {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[object]$Body
|
||||
)
|
||||
|
||||
try {
|
||||
$bodyJson = ConvertTo-JsonCompact $Body
|
||||
Invoke-RestMethod -Uri $u -Method Post -Body $bodyJson -ContentType "application/json" -TimeoutSec 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {
|
||||
# Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
# Get HTTP response
|
||||
function Get-HttpResponse {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[int]$TimeoutSec = 5
|
||||
)
|
||||
|
||||
try {
|
||||
return Invoke-RestMethod -Uri $Uri -Method Get -TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/bin/bash
|
||||
# Common utility functions for Cursor hooks
|
||||
# Source this file in hook scripts: source "$(dirname "$0")/common.sh"
|
||||
|
||||
# Check if required commands exist
|
||||
check_dependencies() {
|
||||
local missing=()
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
missing+=("jq")
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
missing+=("curl")
|
||||
fi
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
echo "Error: Missing required dependencies: ${missing[*]}" >&2
|
||||
echo "Please install: ${missing[*]}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Safely read JSON from stdin with error handling
|
||||
read_json_input() {
|
||||
local input
|
||||
input=$(cat 2>/dev/null || echo "{}")
|
||||
|
||||
# Validate JSON
|
||||
if ! echo "$input" | jq empty 2>/dev/null; then
|
||||
# Invalid JSON - return empty object
|
||||
echo "{}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$input"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Get worker port from settings with validation
|
||||
get_worker_port() {
|
||||
local data_dir="${HOME}/.claude-mem"
|
||||
local settings_file="${data_dir}/settings.json"
|
||||
local port="37777"
|
||||
|
||||
if [ -f "$settings_file" ]; then
|
||||
local parsed_port
|
||||
parsed_port=$(jq -r '.CLAUDE_MEM_WORKER_PORT // "37777"' "$settings_file" 2>/dev/null || echo "37777")
|
||||
|
||||
# Validate port is a number between 1-65535
|
||||
if [[ "$parsed_port" =~ ^[0-9]+$ ]] && [ "$parsed_port" -ge 1 ] && [ "$parsed_port" -le 65535 ]; then
|
||||
port="$parsed_port"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$port"
|
||||
}
|
||||
|
||||
# Ensure worker is running with retries
|
||||
ensure_worker_running() {
|
||||
local port="${1:-37777}"
|
||||
local max_retries="${2:-75}" # 15 seconds total (75 * 0.2s)
|
||||
local retry_count=0
|
||||
|
||||
while [ $retry_count -lt $max_retries ]; do
|
||||
if curl -s -f "http://127.0.0.1:${port}/api/readiness" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
retry_count=$((retry_count + 1))
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# URL encode a string (basic implementation)
|
||||
url_encode() {
|
||||
local string="$1"
|
||||
# Use printf to URL encode
|
||||
printf '%s' "$string" | jq -sRr @uri
|
||||
}
|
||||
|
||||
# Get project name from workspace root
|
||||
get_project_name() {
|
||||
local workspace_root="$1"
|
||||
|
||||
if [ -z "$workspace_root" ]; then
|
||||
echo "unknown-project"
|
||||
return
|
||||
fi
|
||||
|
||||
# Use basename, fallback to unknown-project
|
||||
local project_name
|
||||
project_name=$(basename "$workspace_root" 2>/dev/null || echo "unknown-project")
|
||||
|
||||
# Handle edge case: empty basename (root directory)
|
||||
if [ -z "$project_name" ]; then
|
||||
# Check if it's a Windows drive root
|
||||
if [[ "$workspace_root" =~ ^[A-Za-z]:\\?$ ]]; then
|
||||
local drive_letter
|
||||
drive_letter=$(echo "$workspace_root" | grep -oE '^[A-Za-z]' | tr '[:lower:]' '[:upper:]')
|
||||
echo "drive-${drive_letter}"
|
||||
else
|
||||
echo "unknown-project"
|
||||
fi
|
||||
else
|
||||
echo "$project_name"
|
||||
fi
|
||||
}
|
||||
|
||||
# Safely extract JSON field with fallback
|
||||
# Supports both simple fields (e.g., "conversation_id") and array access (e.g., "workspace_roots[0]")
|
||||
json_get() {
|
||||
local json="$1"
|
||||
local field="$2"
|
||||
local fallback="${3:-}"
|
||||
|
||||
local value
|
||||
|
||||
# Handle array access syntax (e.g., "workspace_roots[0]")
|
||||
if [[ "$field" =~ ^(.+)\[([0-9]+)\]$ ]]; then
|
||||
local array_field="${BASH_REMATCH[1]}"
|
||||
local index="${BASH_REMATCH[2]}"
|
||||
value=$(echo "$json" | jq -r --arg f "$array_field" --arg i "$index" --arg fb "$fallback" '.[$f] // [] | .[$i | tonumber] // $fb' 2>/dev/null || echo "$fallback")
|
||||
else
|
||||
# Simple field access
|
||||
value=$(echo "$json" | jq -r --arg f "$field" --arg fb "$fallback" '.[$f] // $fb' 2>/dev/null || echo "$fallback")
|
||||
fi
|
||||
|
||||
echo "$value"
|
||||
}
|
||||
|
||||
# Check if string is empty or null
|
||||
is_empty() {
|
||||
local str="$1"
|
||||
[ -z "$str" ] || [ "$str" = "null" ] || [ "$str" = "empty" ]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Context Hook for Cursor (beforeSubmitPrompt) - PowerShell
|
||||
# Ensures worker is running and refreshes context before prompt submission
|
||||
#
|
||||
# Context is updated in BOTH places:
|
||||
# - Here (beforeSubmitPrompt): Fresh context at session start
|
||||
# - stop hook (session-summary.ps1): Updated context after observations are made
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Source common utilities
|
||||
$commonPath = Join-Path $PSScriptRoot "common.ps1"
|
||||
if (Test-Path $commonPath) {
|
||||
. $commonPath
|
||||
} else {
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read JSON input from stdin
|
||||
$input = Read-JsonInput
|
||||
|
||||
# Extract workspace root
|
||||
$workspaceRoot = Get-JsonField $input "workspace_roots[0]" ""
|
||||
if (Test-IsEmpty $workspaceRoot) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Get project name
|
||||
$projectName = Get-ProjectName $workspaceRoot
|
||||
|
||||
# Get worker port from settings
|
||||
$workerPort = Get-WorkerPort
|
||||
|
||||
# Ensure worker is running (with retries)
|
||||
# This primes the worker before the session starts
|
||||
if (Test-WorkerReady -Port $workerPort) {
|
||||
# Refresh context file with latest observations
|
||||
$projectEncoded = Get-UrlEncodedString $projectName
|
||||
$contextUri = "http://127.0.0.1:$workerPort/api/context/inject?project=$projectEncoded"
|
||||
$context = Get-HttpResponse -Uri $contextUri
|
||||
|
||||
if (-not [string]::IsNullOrEmpty($context)) {
|
||||
$rulesDir = Join-Path $workspaceRoot ".cursor\rules"
|
||||
$rulesFile = Join-Path $rulesDir "claude-mem-context.mdc"
|
||||
|
||||
# Create rules directory if it doesn't exist
|
||||
if (-not (Test-Path $rulesDir)) {
|
||||
New-Item -ItemType Directory -Path $rulesDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Write context as a Cursor rule with alwaysApply: true
|
||||
$ruleContent = @"
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
$context
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
"@
|
||||
|
||||
Set-Content -Path $rulesFile -Value $ruleContent -Encoding UTF8 -Force
|
||||
}
|
||||
}
|
||||
|
||||
# Allow prompt to continue
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Context Hook for Cursor (beforeSubmitPrompt)
|
||||
# Ensures worker is running and refreshes context before prompt submission
|
||||
#
|
||||
# Context is updated in BOTH places:
|
||||
# - Here (beforeSubmitPrompt): Fresh context at session start
|
||||
# - stop hook (session-summary.sh): Updated context after observations are made
|
||||
|
||||
# Source common utilities
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/common.sh" 2>/dev/null || {
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check dependencies (non-blocking)
|
||||
check_dependencies >/dev/null 2>&1 || true
|
||||
|
||||
# Read JSON input from stdin
|
||||
input=$(read_json_input)
|
||||
|
||||
# Extract workspace root
|
||||
workspace_root=$(json_get "$input" "workspace_roots[0]" "")
|
||||
if is_empty "$workspace_root"; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Get project name
|
||||
project_name=$(get_project_name "$workspace_root")
|
||||
|
||||
# Get worker port from settings
|
||||
worker_port=$(get_worker_port)
|
||||
|
||||
# Ensure worker is running (with retries)
|
||||
# This primes the worker before the session starts
|
||||
if ensure_worker_running "$worker_port" >/dev/null 2>&1; then
|
||||
# Refresh context file with latest observations
|
||||
project_encoded=$(url_encode "$project_name")
|
||||
context=$(curl -s -f "http://127.0.0.1:${worker_port}/api/context/inject?project=${project_encoded}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$context" ]; then
|
||||
rules_dir="${workspace_root}/.cursor/rules"
|
||||
rules_file="${rules_dir}/claude-mem-context.mdc"
|
||||
|
||||
# Create rules directory if it doesn't exist
|
||||
mkdir -p "$rules_dir" 2>/dev/null || true
|
||||
|
||||
# Write context as a Cursor rule with alwaysApply: true
|
||||
cat > "$rules_file" 2>/dev/null << EOF
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# Allow prompt to continue
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Claude-Mem Rules for Cursor
|
||||
|
||||
## Automatic Context Injection
|
||||
|
||||
The `context-inject.sh` hook **automatically creates and updates** a rules file at:
|
||||
|
||||
```
|
||||
.cursor/rules/claude-mem-context.mdc
|
||||
```
|
||||
|
||||
This file:
|
||||
- Has `alwaysApply: true` so it's included in every chat session
|
||||
- Contains recent context from past sessions
|
||||
- Auto-refreshes on every prompt submission
|
||||
|
||||
**You don't need to manually create any rules file!**
|
||||
|
||||
## Optional: Additional Instructions
|
||||
|
||||
If you want to add custom instructions about claude-mem (beyond the auto-injected context), create a separate rules file:
|
||||
|
||||
### `.cursor/rules/claude-mem-instructions.mdc`
|
||||
|
||||
```markdown
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Instructions for using claude-mem memory system"
|
||||
---
|
||||
|
||||
# Memory System Usage
|
||||
|
||||
You have access to claude-mem, a persistent memory system. In addition to the auto-injected context above, you can search for more detailed information using MCP tools:
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
1. **search** - Find relevant past observations
|
||||
```
|
||||
search(query="authentication bug", project="my-project", limit=10)
|
||||
```
|
||||
|
||||
2. **timeline** - Get context around a specific observation
|
||||
```
|
||||
timeline(anchor=<observation_id>, depth_before=3, depth_after=3)
|
||||
```
|
||||
|
||||
3. **get_observations** - Fetch full details for specific IDs
|
||||
```
|
||||
get_observations(ids=[123, 456])
|
||||
```
|
||||
|
||||
## When to Search Memory
|
||||
|
||||
- When the user asks about previous work or decisions
|
||||
- When encountering unfamiliar code patterns in this project
|
||||
- When debugging issues that might have been addressed before
|
||||
- When asked to continue or build upon previous work
|
||||
|
||||
## 3-Layer Workflow
|
||||
|
||||
Follow this pattern for token efficiency:
|
||||
1. **Search first** - Get compact index (~50-100 tokens/result)
|
||||
2. **Timeline** - Get chronological context around interesting results
|
||||
3. **Fetch details** - Only for relevant observations (~500-1000 tokens/result)
|
||||
|
||||
Never fetch full details without filtering first.
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
| File | Purpose | Created By |
|
||||
|------|---------|------------|
|
||||
| `.cursor/rules/claude-mem-context.mdc` | Auto-injected context | Hook (automatic) |
|
||||
| `.cursor/rules/claude-mem-instructions.mdc` | MCP tool instructions | You (optional) |
|
||||
|
||||
## Git Ignore
|
||||
|
||||
If you don't want to commit the auto-generated context file:
|
||||
|
||||
```gitignore
|
||||
# .gitignore
|
||||
.cursor/rules/claude-mem-context.mdc
|
||||
```
|
||||
|
||||
The instructions file can be committed to share with your team.
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"beforeSubmitPrompt": [
|
||||
{
|
||||
"command": "./cursor-hooks/session-init.sh"
|
||||
},
|
||||
{
|
||||
"command": "./cursor-hooks/context-inject.sh"
|
||||
}
|
||||
],
|
||||
"afterMCPExecution": [
|
||||
{
|
||||
"command": "./cursor-hooks/save-observation.sh"
|
||||
}
|
||||
],
|
||||
"afterShellExecution": [
|
||||
{
|
||||
"command": "./cursor-hooks/save-observation.sh"
|
||||
}
|
||||
],
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": "./cursor-hooks/save-file-edit.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "./cursor-hooks/session-summary.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/bash
|
||||
# Installation script for claude-mem Cursor hooks
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALL_TYPE="${1:-user}" # 'user' (recommended) or 'project'
|
||||
|
||||
echo "Installing claude-mem Cursor hooks (${INSTALL_TYPE} level)..."
|
||||
|
||||
case "$INSTALL_TYPE" in
|
||||
"project")
|
||||
if [ ! -d ".cursor" ]; then
|
||||
mkdir -p .cursor
|
||||
fi
|
||||
TARGET_DIR=".cursor"
|
||||
HOOKS_DIR=".cursor/hooks"
|
||||
;;
|
||||
"user")
|
||||
TARGET_DIR="${HOME}/.cursor"
|
||||
HOOKS_DIR="${HOME}/.cursor/hooks"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid install type: $INSTALL_TYPE"
|
||||
echo "Usage: $0 [user (recommended)|project]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create hooks directory
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
|
||||
# Copy hook scripts
|
||||
echo "Copying hook scripts..."
|
||||
cp "$SCRIPT_DIR"/*.sh "$HOOKS_DIR/"
|
||||
chmod +x "$HOOKS_DIR"/*.sh
|
||||
|
||||
# Copy hooks.json
|
||||
echo "Copying hooks.json..."
|
||||
cp "$SCRIPT_DIR/hooks.json" "$TARGET_DIR/hooks.json"
|
||||
|
||||
# Update paths in hooks.json if needed
|
||||
# Use portable sed approach that works on both BSD (macOS) and GNU (Linux) sed
|
||||
if [ "$INSTALL_TYPE" = "project" ]; then
|
||||
# For project-level, paths should be relative
|
||||
# Create temp file, modify, then move (portable across sed variants)
|
||||
tmp_file=$(mktemp)
|
||||
sed 's|\./cursor-hooks/|\./\.cursor/hooks/|g' "$TARGET_DIR/hooks.json" > "$tmp_file"
|
||||
mv "$tmp_file" "$TARGET_DIR/hooks.json"
|
||||
else
|
||||
# For user-level, use absolute paths
|
||||
tmp_file=$(mktemp)
|
||||
sed "s|\./cursor-hooks/|${HOOKS_DIR}/|g" "$TARGET_DIR/hooks.json" > "$tmp_file"
|
||||
mv "$tmp_file" "$TARGET_DIR/hooks.json"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Installation complete!"
|
||||
echo ""
|
||||
echo "Hooks installed to: $TARGET_DIR/hooks.json"
|
||||
echo "Scripts installed to: $HOOKS_DIR"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Ensure claude-mem worker is running:"
|
||||
echo " cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:start"
|
||||
echo ""
|
||||
echo "2. Restart Cursor to load the hooks"
|
||||
echo ""
|
||||
echo "3. Check Cursor Settings → Hooks tab to verify hooks are active"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Save File Edit Hook for Cursor (PowerShell)
|
||||
# Captures file edits made by the agent
|
||||
# Maps file edits to claude-mem observations
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Source common utilities
|
||||
$commonPath = Join-Path $PSScriptRoot "common.ps1"
|
||||
if (Test-Path $commonPath) {
|
||||
. $commonPath
|
||||
} else {
|
||||
Write-Warning "common.ps1 not found, using fallback functions"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
$input = Read-JsonInput
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
$conversationId = Get-JsonField $input "conversation_id" ""
|
||||
$generationId = Get-JsonField $input "generation_id" ""
|
||||
$filePath = Get-JsonField $input "file_path" ""
|
||||
$workspaceRoot = Get-JsonField $input "workspace_roots[0]" ""
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if (Test-IsEmpty $workspaceRoot) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Exit if no file_path
|
||||
if (Test-IsEmpty $filePath) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Use conversation_id as session_id, fallback to generation_id
|
||||
$sessionId = $conversationId
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
$sessionId = $generationId
|
||||
}
|
||||
|
||||
# Exit if no session_id available
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Get worker port from settings with validation
|
||||
$workerPort = Get-WorkerPort
|
||||
|
||||
# Extract edits array, defaulting to [] if invalid
|
||||
$edits = @()
|
||||
if ($input.PSObject.Properties.Name -contains "edits") {
|
||||
$edits = $input.edits
|
||||
if ($null -eq $edits -or -not ($edits -is [array])) {
|
||||
$edits = @()
|
||||
}
|
||||
}
|
||||
|
||||
# Exit if no edits
|
||||
if ($edits.Count -eq 0) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Create a summary of the edits for the observation
|
||||
$editSummaries = @()
|
||||
foreach ($edit in $edits) {
|
||||
$oldStr = ""
|
||||
$newStr = ""
|
||||
|
||||
if ($edit.PSObject.Properties.Name -contains "old_string") {
|
||||
$oldStr = $edit.old_string
|
||||
if ($oldStr.Length -gt 50) {
|
||||
$oldStr = $oldStr.Substring(0, 50) + "..."
|
||||
}
|
||||
}
|
||||
|
||||
if ($edit.PSObject.Properties.Name -contains "new_string") {
|
||||
$newStr = $edit.new_string
|
||||
if ($newStr.Length -gt 50) {
|
||||
$newStr = $newStr.Substring(0, 50) + "..."
|
||||
}
|
||||
}
|
||||
|
||||
$editSummaries += "$oldStr → $newStr"
|
||||
}
|
||||
|
||||
$editSummary = $editSummaries -join "; "
|
||||
if ([string]::IsNullOrEmpty($editSummary)) {
|
||||
$editSummary = "File edited"
|
||||
}
|
||||
|
||||
# Treat file edits as a "write_file" tool usage
|
||||
$toolInput = @{
|
||||
file_path = $filePath
|
||||
edits = $edits
|
||||
}
|
||||
|
||||
$toolResponse = @{
|
||||
success = $true
|
||||
summary = $editSummary
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
contentSessionId = $sessionId
|
||||
tool_name = "write_file"
|
||||
tool_input = $toolInput
|
||||
tool_response = $toolResponse
|
||||
cwd = $workspaceRoot
|
||||
}
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if (-not (Test-WorkerReady -Port $workerPort)) {
|
||||
# Worker not ready - exit gracefully (don't block Cursor)
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Send observation to claude-mem worker (fire-and-forget)
|
||||
$uri = "http://127.0.0.1:$workerPort/api/sessions/observations"
|
||||
|
||||
try {
|
||||
$bodyJson = ConvertTo-JsonCompact $payload
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Body $bodyJson -ContentType "application/json" -TimeoutSec 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {
|
||||
# Ignore errors - don't block Cursor
|
||||
}
|
||||
|
||||
exit 0
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# Save File Edit Hook for Cursor
|
||||
# Captures file edits made by the agent
|
||||
# Maps file edits to claude-mem observations
|
||||
|
||||
# Source common utilities
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/common.sh" 2>/dev/null || {
|
||||
echo "Warning: common.sh not found, using fallback functions" >&2
|
||||
}
|
||||
|
||||
# Check dependencies (non-blocking)
|
||||
check_dependencies >/dev/null 2>&1 || true
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
input=$(read_json_input)
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
conversation_id=$(json_get "$input" "conversation_id" "")
|
||||
generation_id=$(json_get "$input" "generation_id" "")
|
||||
file_path=$(json_get "$input" "file_path" "")
|
||||
workspace_root=$(json_get "$input" "workspace_roots[0]" "")
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if is_empty "$workspace_root"; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Exit if no file_path
|
||||
if is_empty "$file_path"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use conversation_id as session_id, fallback to generation_id
|
||||
session_id="$conversation_id"
|
||||
if is_empty "$session_id"; then
|
||||
session_id="$generation_id"
|
||||
fi
|
||||
|
||||
# Exit if no session_id available
|
||||
if is_empty "$session_id"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get worker port from settings with validation
|
||||
worker_port=$(get_worker_port)
|
||||
|
||||
# Extract edits array, defaulting to [] if invalid
|
||||
edits=$(echo "$input" | jq -c '.edits // []' 2>/dev/null || echo "[]")
|
||||
|
||||
# Validate edits is a valid JSON array
|
||||
if ! echo "$edits" | jq 'type == "array"' 2>/dev/null | grep -q true; then
|
||||
edits="[]"
|
||||
fi
|
||||
|
||||
# Exit if no edits
|
||||
if [ "$edits" = "[]" ] || is_empty "$edits"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create a summary of the edits for the observation (with error handling)
|
||||
edit_summary=$(echo "$edits" | jq -r '[.[] | "\(.old_string[0:50] // "")... → \(.new_string[0:50] // "")..."] | join("; ")' 2>/dev/null || echo "File edited")
|
||||
|
||||
# Treat file edits as a "write_file" tool usage
|
||||
tool_input=$(jq -n \
|
||||
--arg path "$file_path" \
|
||||
--argjson edits "$edits" \
|
||||
'{
|
||||
file_path: $path,
|
||||
edits: $edits
|
||||
}' 2>/dev/null || echo '{}')
|
||||
|
||||
tool_response=$(jq -n \
|
||||
--arg summary "$edit_summary" \
|
||||
'{
|
||||
success: true,
|
||||
summary: $summary
|
||||
}' 2>/dev/null || echo '{}')
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg sessionId "$session_id" \
|
||||
--arg cwd "$workspace_root" \
|
||||
--argjson toolInput "$tool_input" \
|
||||
--argjson toolResponse "$tool_response" \
|
||||
'{
|
||||
contentSessionId: $sessionId,
|
||||
tool_name: "write_file",
|
||||
tool_input: $toolInput,
|
||||
tool_response: $toolResponse,
|
||||
cwd: $cwd
|
||||
}' 2>/dev/null)
|
||||
|
||||
# Exit if payload creation failed
|
||||
if [ -z "$payload" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if ! ensure_worker_running "$worker_port"; then
|
||||
# Worker not ready - exit gracefully (don't block Cursor)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Send observation to claude-mem worker (fire-and-forget)
|
||||
curl -s -X POST \
|
||||
"http://127.0.0.1:${worker_port}/api/sessions/observations" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Save Observation Hook for Cursor (PowerShell)
|
||||
# Captures MCP tool usage and shell command execution
|
||||
# Maps to claude-mem's save-hook functionality
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Source common utilities
|
||||
$commonPath = Join-Path $PSScriptRoot "common.ps1"
|
||||
if (Test-Path $commonPath) {
|
||||
. $commonPath
|
||||
} else {
|
||||
Write-Warning "common.ps1 not found, using fallback functions"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
$input = Read-JsonInput
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
$conversationId = Get-JsonField $input "conversation_id" ""
|
||||
$generationId = Get-JsonField $input "generation_id" ""
|
||||
$workspaceRoot = Get-JsonField $input "workspace_roots[0]" ""
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if (Test-IsEmpty $workspaceRoot) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Use conversation_id as session_id (stable across turns), fallback to generation_id
|
||||
$sessionId = $conversationId
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
$sessionId = $generationId
|
||||
}
|
||||
|
||||
# Exit if no session_id available
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Get worker port from settings with validation
|
||||
$workerPort = Get-WorkerPort
|
||||
|
||||
# Determine hook type and extract relevant data
|
||||
$hookEvent = Get-JsonField $input "hook_event_name" ""
|
||||
|
||||
$payload = $null
|
||||
|
||||
if ($hookEvent -eq "afterMCPExecution") {
|
||||
# MCP tool execution
|
||||
$toolName = Get-JsonField $input "tool_name" ""
|
||||
|
||||
if (Test-IsEmpty $toolName) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Extract tool_input and tool_response, defaulting to {} if invalid
|
||||
$toolInput = @{}
|
||||
$toolResponse = @{}
|
||||
|
||||
if ($input.PSObject.Properties.Name -contains "tool_input") {
|
||||
$toolInput = $input.tool_input
|
||||
if ($null -eq $toolInput) { $toolInput = @{} }
|
||||
}
|
||||
|
||||
if ($input.PSObject.Properties.Name -contains "result_json") {
|
||||
$toolResponse = $input.result_json
|
||||
if ($null -eq $toolResponse) { $toolResponse = @{} }
|
||||
}
|
||||
|
||||
# Prepare observation payload
|
||||
$payload = @{
|
||||
contentSessionId = $sessionId
|
||||
tool_name = $toolName
|
||||
tool_input = $toolInput
|
||||
tool_response = $toolResponse
|
||||
cwd = $workspaceRoot
|
||||
}
|
||||
|
||||
} elseif ($hookEvent -eq "afterShellExecution") {
|
||||
# Shell command execution
|
||||
$command = Get-JsonField $input "command" ""
|
||||
|
||||
if (Test-IsEmpty $command) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$output = Get-JsonField $input "output" ""
|
||||
|
||||
# Treat shell commands as "Bash" tool usage
|
||||
$toolInput = @{ command = $command }
|
||||
$toolResponse = @{ output = $output }
|
||||
|
||||
$payload = @{
|
||||
contentSessionId = $sessionId
|
||||
tool_name = "Bash"
|
||||
tool_input = $toolInput
|
||||
tool_response = $toolResponse
|
||||
cwd = $workspaceRoot
|
||||
}
|
||||
|
||||
} else {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Exit if payload creation failed
|
||||
if ($null -eq $payload) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if (-not (Test-WorkerReady -Port $workerPort)) {
|
||||
# Worker not ready - exit gracefully (don't block Cursor)
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Send observation to claude-mem worker (fire-and-forget)
|
||||
$uri = "http://127.0.0.1:$workerPort/api/sessions/observations"
|
||||
|
||||
try {
|
||||
$bodyJson = ConvertTo-JsonCompact $payload
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Body $bodyJson -ContentType "application/json" -TimeoutSec 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {
|
||||
# Ignore errors - don't block Cursor
|
||||
}
|
||||
|
||||
exit 0
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/bin/bash
|
||||
# Save Observation Hook for Cursor
|
||||
# Captures MCP tool usage and shell command execution
|
||||
# Maps to claude-mem's save-hook functionality
|
||||
|
||||
# Source common utilities
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/common.sh" 2>/dev/null || {
|
||||
echo "Warning: common.sh not found, using fallback functions" >&2
|
||||
}
|
||||
|
||||
# Check dependencies (non-blocking)
|
||||
check_dependencies >/dev/null 2>&1 || true
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
input=$(read_json_input)
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
conversation_id=$(json_get "$input" "conversation_id" "")
|
||||
generation_id=$(json_get "$input" "generation_id" "")
|
||||
workspace_root=$(json_get "$input" "workspace_roots[0]" "")
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if is_empty "$workspace_root"; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Use conversation_id as session_id (stable across turns), fallback to generation_id
|
||||
session_id="$conversation_id"
|
||||
if is_empty "$session_id"; then
|
||||
session_id="$generation_id"
|
||||
fi
|
||||
|
||||
# Exit if no session_id available
|
||||
if is_empty "$session_id"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get worker port from settings with validation
|
||||
worker_port=$(get_worker_port)
|
||||
|
||||
# Determine hook type and extract relevant data
|
||||
hook_event=$(json_get "$input" "hook_event_name" "")
|
||||
|
||||
if [ "$hook_event" = "afterMCPExecution" ]; then
|
||||
# MCP tool execution
|
||||
tool_name=$(json_get "$input" "tool_name" "")
|
||||
|
||||
if is_empty "$tool_name"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract tool_input and tool_response, defaulting to {} if invalid
|
||||
tool_input=$(echo "$input" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
|
||||
tool_response=$(echo "$input" | jq -c '.result_json // {}' 2>/dev/null || echo "{}")
|
||||
|
||||
# Validate JSON
|
||||
if ! echo "$tool_input" | jq empty 2>/dev/null; then
|
||||
tool_input="{}"
|
||||
fi
|
||||
if ! echo "$tool_response" | jq empty 2>/dev/null; then
|
||||
tool_response="{}"
|
||||
fi
|
||||
|
||||
# Prepare observation payload
|
||||
payload=$(jq -n \
|
||||
--arg sessionId "$session_id" \
|
||||
--arg toolName "$tool_name" \
|
||||
--argjson toolInput "$tool_input" \
|
||||
--argjson toolResponse "$tool_response" \
|
||||
--arg cwd "$workspace_root" \
|
||||
'{
|
||||
contentSessionId: $sessionId,
|
||||
tool_name: $toolName,
|
||||
tool_input: $toolInput,
|
||||
tool_response: $toolResponse,
|
||||
cwd: $cwd
|
||||
}' 2>/dev/null)
|
||||
|
||||
elif [ "$hook_event" = "afterShellExecution" ]; then
|
||||
# Shell command execution
|
||||
command=$(json_get "$input" "command" "")
|
||||
|
||||
if is_empty "$command"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
output=$(json_get "$input" "output" "")
|
||||
|
||||
# Treat shell commands as "Bash" tool usage
|
||||
tool_input=$(jq -n --arg cmd "$command" '{command: $cmd}' 2>/dev/null || echo '{}')
|
||||
tool_response=$(jq -n --arg out "$output" '{output: $out}' 2>/dev/null || echo '{}')
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg sessionId "$session_id" \
|
||||
--arg cwd "$workspace_root" \
|
||||
--argjson toolInput "$tool_input" \
|
||||
--argjson toolResponse "$tool_response" \
|
||||
'{
|
||||
contentSessionId: $sessionId,
|
||||
tool_name: "Bash",
|
||||
tool_input: $toolInput,
|
||||
tool_response: $toolResponse,
|
||||
cwd: $cwd
|
||||
}' 2>/dev/null)
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Exit if payload creation failed
|
||||
if [ -z "$payload" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if ! ensure_worker_running "$worker_port"; then
|
||||
# Worker not ready - exit gracefully (don't block Cursor)
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Send observation to claude-mem worker (fire-and-forget)
|
||||
curl -s -X POST \
|
||||
"http://127.0.0.1:${worker_port}/api/sessions/observations" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Session Initialization Hook for Cursor (PowerShell)
|
||||
# Maps to claude-mem's new-hook functionality
|
||||
# Initializes a new session when a prompt is submitted
|
||||
#
|
||||
# NOTE: This hook runs as part of beforeSubmitPrompt and MUST output valid JSON
|
||||
# with at least {"continue": true} to allow prompt submission.
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Source common utilities
|
||||
$commonPath = Join-Path $PSScriptRoot "common.ps1"
|
||||
if (Test-Path $commonPath) {
|
||||
. $commonPath
|
||||
} else {
|
||||
# Fallback - output continue and exit
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
$input = Read-JsonInput
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
$conversationId = Get-JsonField $input "conversation_id" ""
|
||||
$generationId = Get-JsonField $input "generation_id" ""
|
||||
$prompt = Get-JsonField $input "prompt" ""
|
||||
$workspaceRoot = Get-JsonField $input "workspace_roots[0]" ""
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if (Test-IsEmpty $workspaceRoot) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Get project name from workspace root
|
||||
$projectName = Get-ProjectName $workspaceRoot
|
||||
|
||||
# Use conversation_id as session_id (stable across turns), fallback to generation_id
|
||||
$sessionId = $conversationId
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
$sessionId = $generationId
|
||||
}
|
||||
|
||||
# Exit gracefully if no session_id available (still allow prompt)
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Get worker port from settings with validation
|
||||
$workerPort = Get-WorkerPort
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if (-not (Test-WorkerReady -Port $workerPort)) {
|
||||
# Worker not ready - still allow prompt to continue
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Strip leading slash from commands for memory agent (parity with new-hook.ts)
|
||||
# /review 101 → review 101 (more semantic for observations)
|
||||
$cleanedPrompt = $prompt
|
||||
if (-not [string]::IsNullOrEmpty($prompt) -and $prompt.StartsWith("/")) {
|
||||
$cleanedPrompt = $prompt.Substring(1)
|
||||
}
|
||||
|
||||
# Initialize session via HTTP - handles DB operations and privacy checks
|
||||
$payload = @{
|
||||
contentSessionId = $sessionId
|
||||
project = $projectName
|
||||
prompt = $cleanedPrompt
|
||||
}
|
||||
|
||||
# Send request to worker (fire-and-forget, don't wait for response)
|
||||
$uri = "http://127.0.0.1:$workerPort/api/sessions/init"
|
||||
Send-HttpPostAsync -Uri $uri -Body $payload
|
||||
|
||||
# Always allow prompt to continue
|
||||
Write-Output '{"continue": true}'
|
||||
exit 0
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
# Session Initialization Hook for Cursor
|
||||
# Maps to claude-mem's new-hook functionality
|
||||
# Initializes a new session when a prompt is submitted
|
||||
#
|
||||
# NOTE: This hook runs as part of beforeSubmitPrompt and MUST output valid JSON
|
||||
# with at least {"continue": true} to allow prompt submission.
|
||||
|
||||
# Source common utilities
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/common.sh" 2>/dev/null || {
|
||||
# Fallback - output continue and exit
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check dependencies (non-blocking - just warn)
|
||||
check_dependencies >/dev/null 2>&1 || true
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
input=$(read_json_input)
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
conversation_id=$(json_get "$input" "conversation_id" "")
|
||||
generation_id=$(json_get "$input" "generation_id" "")
|
||||
prompt=$(json_get "$input" "prompt" "")
|
||||
workspace_root=$(json_get "$input" "workspace_roots[0]" "")
|
||||
|
||||
# Fallback to current directory if no workspace root
|
||||
if is_empty "$workspace_root"; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Get project name from workspace root
|
||||
project_name=$(get_project_name "$workspace_root")
|
||||
|
||||
# Use conversation_id as session_id (stable across turns), fallback to generation_id
|
||||
session_id="$conversation_id"
|
||||
if is_empty "$session_id"; then
|
||||
session_id="$generation_id"
|
||||
fi
|
||||
|
||||
# Exit gracefully if no session_id available (still allow prompt)
|
||||
if is_empty "$session_id"; then
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get worker port from settings with validation
|
||||
worker_port=$(get_worker_port)
|
||||
|
||||
# Ensure worker is running (with retries like claude-mem hooks)
|
||||
if ! ensure_worker_running "$worker_port"; then
|
||||
# Worker not ready - still allow prompt to continue
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Strip leading slash from commands for memory agent (parity with new-hook.ts)
|
||||
# /review 101 → review 101 (more semantic for observations)
|
||||
cleaned_prompt="$prompt"
|
||||
if [ -n "$prompt" ] && [ "${prompt:0:1}" = "/" ]; then
|
||||
cleaned_prompt="${prompt:1}"
|
||||
fi
|
||||
|
||||
# Initialize session via HTTP - handles DB operations and privacy checks
|
||||
payload=$(jq -n \
|
||||
--arg sessionId "$session_id" \
|
||||
--arg project "$project_name" \
|
||||
--arg promptText "$cleaned_prompt" \
|
||||
'{
|
||||
contentSessionId: $sessionId,
|
||||
project: $project,
|
||||
prompt: $promptText
|
||||
}' 2>/dev/null)
|
||||
|
||||
# Exit if payload creation failed (still allow prompt)
|
||||
if [ -z "$payload" ]; then
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Send request to worker (fire-and-forget, don't wait for response)
|
||||
curl -s -X POST \
|
||||
"http://127.0.0.1:${worker_port}/api/sessions/init" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
>/dev/null 2>&1 &
|
||||
|
||||
# Always allow prompt to continue
|
||||
echo '{"continue": true}'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Session Summary Hook for Cursor (stop) - PowerShell
|
||||
# Called when agent loop ends
|
||||
#
|
||||
# This hook:
|
||||
# 1. Generates session summary
|
||||
# 2. Updates context file for next session
|
||||
#
|
||||
# Output: Empty JSON {} or {"followup_message": "..."} for auto-iteration
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Source common utilities
|
||||
$commonPath = Join-Path $PSScriptRoot "common.ps1"
|
||||
if (Test-Path $commonPath) {
|
||||
. $commonPath
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
$input = Read-JsonInput
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
$conversationId = Get-JsonField $input "conversation_id" ""
|
||||
$generationId = Get-JsonField $input "generation_id" ""
|
||||
$workspaceRoot = Get-JsonField $input "workspace_roots[0]" ""
|
||||
$status = Get-JsonField $input "status" "completed"
|
||||
|
||||
# Fallback workspace to current directory
|
||||
if (Test-IsEmpty $workspaceRoot) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Get project name
|
||||
$projectName = Get-ProjectName $workspaceRoot
|
||||
|
||||
# Use conversation_id as session_id, fallback to generation_id
|
||||
$sessionId = $conversationId
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
$sessionId = $generationId
|
||||
}
|
||||
|
||||
# Exit if no session_id available
|
||||
if (Test-IsEmpty $sessionId) {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Get worker port from settings with validation
|
||||
$workerPort = Get-WorkerPort
|
||||
|
||||
# Ensure worker is running (with retries)
|
||||
if (-not (Test-WorkerReady -Port $workerPort)) {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 1. Request summary generation (fire-and-forget)
|
||||
# Note: Cursor doesn't provide transcript_path like Claude Code does,
|
||||
# so we can't extract last_user_message and last_assistant_message.
|
||||
$summaryPayload = @{
|
||||
contentSessionId = $sessionId
|
||||
last_user_message = ""
|
||||
last_assistant_message = ""
|
||||
}
|
||||
|
||||
$summaryUri = "http://127.0.0.1:$workerPort/api/sessions/summarize"
|
||||
Send-HttpPostAsync -Uri $summaryUri -Body $summaryPayload
|
||||
|
||||
# 2. Update context file for next session
|
||||
# Fetch fresh context (includes observations from this session)
|
||||
$projectEncoded = Get-UrlEncodedString $projectName
|
||||
$contextUri = "http://127.0.0.1:$workerPort/api/context/inject?project=$projectEncoded"
|
||||
$context = Get-HttpResponse -Uri $contextUri
|
||||
|
||||
if (-not [string]::IsNullOrEmpty($context)) {
|
||||
$rulesDir = Join-Path $workspaceRoot ".cursor\rules"
|
||||
$rulesFile = Join-Path $rulesDir "claude-mem-context.mdc"
|
||||
|
||||
# Create rules directory if it doesn't exist
|
||||
if (-not (Test-Path $rulesDir)) {
|
||||
New-Item -ItemType Directory -Path $rulesDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Write context as a Cursor rule with alwaysApply: true
|
||||
$ruleContent = @"
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
$context
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
"@
|
||||
|
||||
Set-Content -Path $rulesFile -Value $ruleContent -Encoding UTF8 -Force
|
||||
}
|
||||
|
||||
# Output empty JSON - no followup message
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
# Session Summary Hook for Cursor (stop)
|
||||
# Called when agent loop ends
|
||||
#
|
||||
# This hook:
|
||||
# 1. Generates session summary
|
||||
# 2. Updates context file for next session
|
||||
#
|
||||
# Output: Empty JSON {} or {"followup_message": "..."} for auto-iteration
|
||||
|
||||
# Source common utilities
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/common.sh" 2>/dev/null || {
|
||||
echo '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check dependencies (non-blocking)
|
||||
check_dependencies >/dev/null 2>&1 || true
|
||||
|
||||
# Read JSON input from stdin with error handling
|
||||
input=$(read_json_input)
|
||||
|
||||
# Extract common fields with safe fallbacks
|
||||
conversation_id=$(json_get "$input" "conversation_id" "")
|
||||
generation_id=$(json_get "$input" "generation_id" "")
|
||||
workspace_root=$(json_get "$input" "workspace_roots[0]" "")
|
||||
status=$(json_get "$input" "status" "completed")
|
||||
|
||||
# Fallback workspace to current directory
|
||||
if is_empty "$workspace_root"; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Get project name
|
||||
project_name=$(get_project_name "$workspace_root")
|
||||
|
||||
# Use conversation_id as session_id, fallback to generation_id
|
||||
session_id="$conversation_id"
|
||||
if is_empty "$session_id"; then
|
||||
session_id="$generation_id"
|
||||
fi
|
||||
|
||||
# Exit if no session_id available
|
||||
if is_empty "$session_id"; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Get worker port from settings with validation
|
||||
worker_port=$(get_worker_port)
|
||||
|
||||
# Ensure worker is running (with retries)
|
||||
if ! ensure_worker_running "$worker_port"; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1. Request summary generation (fire-and-forget)
|
||||
# Note: Cursor doesn't provide transcript_path like Claude Code does,
|
||||
# so we can't extract last_user_message and last_assistant_message.
|
||||
payload=$(jq -n \
|
||||
--arg sessionId "$session_id" \
|
||||
'{
|
||||
contentSessionId: $sessionId,
|
||||
last_user_message: "",
|
||||
last_assistant_message: ""
|
||||
}' 2>/dev/null)
|
||||
|
||||
if [ -n "$payload" ]; then
|
||||
curl -s -X POST \
|
||||
"http://127.0.0.1:${worker_port}/api/sessions/summarize" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
>/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
# 2. Update context file for next session
|
||||
# Fetch fresh context (includes observations from this session)
|
||||
project_encoded=$(url_encode "$project_name")
|
||||
context=$(curl -s -f "http://127.0.0.1:${worker_port}/api/context/inject?project=${project_encoded}" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$context" ]; then
|
||||
rules_dir="${workspace_root}/.cursor/rules"
|
||||
rules_file="${rules_dir}/claude-mem-context.mdc"
|
||||
|
||||
# Create rules directory if it doesn't exist
|
||||
mkdir -p "$rules_dir" 2>/dev/null || true
|
||||
|
||||
# Write context as a Cursor rule with alwaysApply: true
|
||||
cat > "$rules_file" 2>/dev/null << EOF
|
||||
---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Output empty JSON - no followup message
|
||||
echo '{}'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# User Message Hook for Cursor (PowerShell)
|
||||
# Displays context information to the user
|
||||
# Maps to claude-mem's user-message-hook functionality
|
||||
# Note: Cursor doesn't have a direct equivalent, but we can output to stderr
|
||||
# for visibility in Cursor's output channels
|
||||
#
|
||||
# This is an OPTIONAL hook. It can be added to beforeSubmitPrompt if desired,
|
||||
# but may be verbose since it runs on every prompt submission.
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
# Read JSON input from stdin (if any)
|
||||
$inputJson = $null
|
||||
try {
|
||||
$inputText = [Console]::In.ReadToEnd()
|
||||
if (-not [string]::IsNullOrEmpty($inputText)) {
|
||||
$inputJson = $inputText | ConvertFrom-Json -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
$inputJson = $null
|
||||
}
|
||||
|
||||
# Extract workspace root
|
||||
$workspaceRoot = ""
|
||||
if ($null -ne $inputJson -and $inputJson.PSObject.Properties.Name -contains "workspace_roots") {
|
||||
$wsRoots = $inputJson.workspace_roots
|
||||
if ($null -ne $wsRoots -and $wsRoots.Count -gt 0) {
|
||||
$workspaceRoot = $wsRoots[0]
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrEmpty($workspaceRoot)) {
|
||||
$workspaceRoot = Get-Location
|
||||
}
|
||||
|
||||
# Get project name
|
||||
$projectName = Split-Path $workspaceRoot -Leaf
|
||||
if ([string]::IsNullOrEmpty($projectName)) {
|
||||
$projectName = "unknown-project"
|
||||
}
|
||||
|
||||
# Get worker port from settings
|
||||
$settingsPath = Join-Path $env:USERPROFILE ".claude-mem\settings.json"
|
||||
$workerPort = 37777
|
||||
|
||||
if (Test-Path $settingsPath) {
|
||||
try {
|
||||
$settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
|
||||
if ($settings.CLAUDE_MEM_WORKER_PORT) {
|
||||
$workerPort = [int]$settings.CLAUDE_MEM_WORKER_PORT
|
||||
}
|
||||
} catch {
|
||||
# Use default
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure worker is running
|
||||
$maxRetries = 75
|
||||
$workerReady = $false
|
||||
|
||||
for ($i = 0; $i -lt $maxRetries; $i++) {
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri "http://127.0.0.1:$workerPort/api/readiness" -Method Get -TimeoutSec 1 -ErrorAction Stop
|
||||
$workerReady = $true
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
}
|
||||
|
||||
# If worker not ready, exit silently
|
||||
if (-not $workerReady) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Fetch formatted context from worker API (with colors)
|
||||
$projectEncoded = [System.Uri]::EscapeDataString($projectName)
|
||||
$contextUrl = "http://127.0.0.1:$workerPort/api/context/inject?project=$projectEncoded&colors=true"
|
||||
|
||||
$output = $null
|
||||
try {
|
||||
$output = Invoke-RestMethod -Uri $contextUrl -Method Get -TimeoutSec 5 -ErrorAction Stop
|
||||
} catch {
|
||||
$output = $null
|
||||
}
|
||||
|
||||
# Output to stderr for visibility (parity with user-message-hook.ts)
|
||||
# Note: Cursor may not display stderr the same way Claude Code does,
|
||||
# but this is the best we can do without direct UI integration
|
||||
if (-not [string]::IsNullOrEmpty($output)) {
|
||||
[Console]::Error.WriteLine("")
|
||||
[Console]::Error.WriteLine("📝 Claude-Mem Context Loaded")
|
||||
[Console]::Error.WriteLine(" ℹ️ Viewing context from past sessions")
|
||||
[Console]::Error.WriteLine("")
|
||||
[Console]::Error.WriteLine($output)
|
||||
[Console]::Error.WriteLine("")
|
||||
[Console]::Error.WriteLine("💡 Tip: Wrap content with <private> ... </private> to prevent storing sensitive information.")
|
||||
[Console]::Error.WriteLine("💬 Community: https://discord.gg/J4wttp9vDu")
|
||||
[Console]::Error.WriteLine("📺 Web Viewer: http://localhost:$workerPort/")
|
||||
[Console]::Error.WriteLine("")
|
||||
}
|
||||
|
||||
exit 0
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# User Message Hook for Cursor
|
||||
# Displays context information to the user
|
||||
# Maps to claude-mem's user-message-hook functionality
|
||||
# Note: Cursor doesn't have a direct equivalent, but we can output to stderr
|
||||
# for visibility in Cursor's output channels
|
||||
#
|
||||
# This is an OPTIONAL hook. It can be added to beforeSubmitPrompt if desired,
|
||||
# but may be verbose since it runs on every prompt submission.
|
||||
|
||||
# Read JSON input from stdin (if any)
|
||||
input=$(cat 2>/dev/null || echo "{}")
|
||||
|
||||
# Extract workspace root
|
||||
workspace_root=$(echo "$input" | jq -r '.workspace_roots[0] // empty' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$workspace_root" ]; then
|
||||
workspace_root=$(pwd)
|
||||
fi
|
||||
|
||||
# Get project name
|
||||
project_name=$(basename "$workspace_root" 2>/dev/null || echo "unknown-project")
|
||||
|
||||
# Get worker port from settings
|
||||
data_dir="${HOME}/.claude-mem"
|
||||
settings_file="${data_dir}/settings.json"
|
||||
worker_port="37777"
|
||||
|
||||
if [ -f "$settings_file" ]; then
|
||||
worker_port=$(jq -r '.CLAUDE_MEM_WORKER_PORT // "37777"' "$settings_file" 2>/dev/null || echo "37777")
|
||||
fi
|
||||
|
||||
# Ensure worker is running
|
||||
max_retries=75
|
||||
retry_count=0
|
||||
while [ $retry_count -lt $max_retries ]; do
|
||||
if curl -s -f "http://127.0.0.1:${worker_port}/api/readiness" > /dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
retry_count=$((retry_count + 1))
|
||||
done
|
||||
|
||||
# If worker not ready, exit silently
|
||||
if [ $retry_count -eq $max_retries ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fetch formatted context from worker API (with colors)
|
||||
context_url="http://127.0.0.1:${worker_port}/api/context/inject?project=${project_name}&colors=true"
|
||||
output=$(curl -s -f "$context_url" 2>/dev/null || echo "")
|
||||
|
||||
# Output to stderr for visibility (parity with user-message-hook.ts)
|
||||
# Note: Cursor may not display stderr the same way Claude Code does,
|
||||
# but this is the best we can do without direct UI integration
|
||||
if [ -n "$output" ]; then
|
||||
echo "" >&2
|
||||
echo "📝 Claude-Mem Context Loaded" >&2
|
||||
echo " ℹ️ Viewing context from past sessions" >&2
|
||||
echo "" >&2
|
||||
echo "$output" >&2
|
||||
echo "" >&2
|
||||
echo "💡 Tip: Wrap content with <private> ... </private> to prevent storing sensitive information." >&2
|
||||
echo "💬 Community: https://discord.gg/J4wttp9vDu" >&2
|
||||
echo "📺 Web Viewer: http://localhost:${worker_port}/" >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
# Hooks
|
||||
|
||||
Hooks let you observe, control, and extend the agent loop using custom scripts. Hooks are spawned processes that communicate over stdio using JSON in both directions. They run before or after defined stages of the agent loop and can observe, block, or modify behavior.
|
||||
|
||||
With hooks, you can:
|
||||
|
||||
- Run formatters after edits
|
||||
- Add analytics for events
|
||||
- Scan for PII or secrets
|
||||
- Gate risky operations (e.g., SQL writes)
|
||||
|
||||
<Tip>
|
||||
Looking for ready-to-use integrations? See [Partner Integrations](#partner-integrations) for security, governance, and secrets management solutions from our ecosystem partners.
|
||||
</Tip>
|
||||
|
||||
## Agent and Tab Support
|
||||
|
||||
Hooks work with both **Cursor Agent** (Cmd+K/Agent Chat) and **Cursor Tab** (inline completions), but they use different hook events:
|
||||
|
||||
**Agent (Cmd+K/Agent Chat)** uses the standard hooks:
|
||||
- `beforeShellExecution` / `afterShellExecution` - Control shell commands
|
||||
- `beforeMCPExecution` / `afterMCPExecution` - Control MCP tool usage
|
||||
- `beforeReadFile` / `afterFileEdit` - Control file access and edits
|
||||
- `beforeSubmitPrompt` - Validate prompts before submission
|
||||
- `stop` - Handle agent completion
|
||||
- `afterAgentResponse` / `afterAgentThought` - Track agent responses
|
||||
|
||||
**Tab (inline completions)** uses specialized hooks:
|
||||
- `beforeTabFileRead` - Control file access for Tab completions
|
||||
- `afterTabFileEdit` - Post-process Tab edits
|
||||
|
||||
These separate hooks allow different policies for autonomous Tab operations versus user-directed Agent operations.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Create a `hooks.json` file. You can create it at the project level (`<project>/.cursor/hooks.json`) or in your home directory (`~/.cursor/hooks.json`). Project-level hooks apply only to that specific project, while home directory hooks apply globally.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [{ "command": "./hooks/format.sh" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create your hook script at `~/.cursor/hooks/format.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Read input, do something, exit 0
|
||||
cat > /dev/null
|
||||
exit 0
|
||||
```
|
||||
|
||||
Make it executable:
|
||||
|
||||
```bash
|
||||
chmod +x ~/.cursor/hooks/format.sh
|
||||
```
|
||||
|
||||
Restart Cursor. Your hook now runs after every file edit.
|
||||
|
||||
## Examples
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```json title="hooks.json"
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"beforeShellExecution": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
},
|
||||
{
|
||||
"command": "./hooks/block-git.sh"
|
||||
}
|
||||
],
|
||||
"beforeMCPExecution": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"afterShellExecution": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"afterMCPExecution": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"beforeSubmitPrompt": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "./hooks/audit.sh"
|
||||
}
|
||||
],
|
||||
"beforeTabFileRead": [
|
||||
{
|
||||
"command": "./hooks/redact-secrets-tab.sh"
|
||||
}
|
||||
],
|
||||
"afterTabFileEdit": [
|
||||
{
|
||||
"command": "./hooks/format-tab.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sh title="audit.sh"
|
||||
#!/bin/bash
|
||||
|
||||
# audit.sh - Hook script that writes all JSON input to /tmp/agent-audit.log
|
||||
# This script is designed to be called by Cursor's hooks system for auditing purposes
|
||||
|
||||
# Read JSON input from stdin
|
||||
json_input=$(cat)
|
||||
|
||||
# Create timestamp for the log entry
|
||||
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Create the log directory if it doesn't exist
|
||||
mkdir -p "$(dirname /tmp/agent-audit.log)"
|
||||
|
||||
# Write the timestamped JSON entry to the audit log
|
||||
echo "[$timestamp] $json_input" >> /tmp/agent-audit.log
|
||||
|
||||
# Exit successfully
|
||||
exit 0
|
||||
```
|
||||
|
||||
```sh title="block-git.sh"
|
||||
#!/bin/bash
|
||||
|
||||
# Hook to block git commands and redirect to gh tool usage
|
||||
# This hook implements the beforeShellExecution hook from the Cursor Hooks Spec
|
||||
|
||||
# Initialize debug logging
|
||||
echo "Hook execution started" >> /tmp/hooks.log
|
||||
|
||||
# Read JSON input from stdin
|
||||
input=$(cat)
|
||||
echo "Received input: $input" >> /tmp/hooks.log
|
||||
|
||||
# Parse the command from the JSON input
|
||||
command=$(echo "$input" | jq -r '.command // empty')
|
||||
echo "Parsed command: '$command'" >> /tmp/hooks.log
|
||||
|
||||
# Check if the command contains 'git' or 'gh'
|
||||
if [[ "$command" =~ git[[:space:]] ]] || [[ "$command" == "git" ]]; then
|
||||
echo "Git command detected - blocking: '$command'" >> /tmp/hooks.log
|
||||
# Block the git command and provide guidance to use gh tool instead
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"permission": "deny",
|
||||
"user_message": "Git command blocked. Please use the GitHub CLI (gh) tool instead.",
|
||||
"agent_message": "The git command '$command' has been blocked by a hook. Instead of using raw git commands, please use the 'gh' tool which provides better integration with GitHub and follows best practices. For example:\n- Instead of 'git clone', use 'gh repo clone'\n- Instead of 'git push', use 'gh repo sync' or the appropriate gh command\n- For other git operations, check if there's an equivalent gh command or use the GitHub web interface\n\nThis helps maintain consistency and leverages GitHub's enhanced tooling."
|
||||
}
|
||||
EOF
|
||||
elif [[ "$command" =~ gh[[:space:]] ]] || [[ "$command" == "gh" ]]; then
|
||||
echo "GitHub CLI command detected - asking for permission: '$command'" >> /tmp/hooks.log
|
||||
# Ask for permission for gh commands
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"permission": "ask",
|
||||
"user_message": "GitHub CLI command requires permission: $command",
|
||||
"agent_message": "The command '$command' uses the GitHub CLI (gh) which can interact with your GitHub repositories and account. Please review and approve this command if you want to proceed."
|
||||
}
|
||||
EOF
|
||||
else
|
||||
echo "Non-git/non-gh command detected - allowing: '$command'" >> /tmp/hooks.log
|
||||
# Allow non-git/non-gh commands
|
||||
cat << EOF
|
||||
{
|
||||
"continue": true,
|
||||
"permission": "allow"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Partner Integrations
|
||||
|
||||
We partner with ecosystem vendors who have built hooks support with Cursor. These integrations cover security scanning, governance, secrets management, and more.
|
||||
|
||||
### MCP governance and visibility
|
||||
|
||||
| Partner | Description |
|
||||
|---------|-------------|
|
||||
| [MintMCP](https://www.mintmcp.com/blog/mcp-governance-cursor-hooks) | Build a complete inventory of MCP servers, monitor tool usage patterns, and scan responses for sensitive data before it reaches the AI model. |
|
||||
| [Oasis Security](https://www.oasis.security/blog/cursor-oasis-governing-agentic-access) | Enforce least-privilege policies on AI agent actions and maintain full audit trails across enterprise systems. |
|
||||
| [Runlayer](https://www.runlayer.com/blog/cursor-hooks) | Wrap MCP tools and integrate with their MCP broker for centralized control and visibility over agent-to-tool interactions. |
|
||||
|
||||
### Code security and best practices
|
||||
|
||||
| Partner | Description |
|
||||
|---------|-------------|
|
||||
| [Corridor](https://corridor.dev/blog/corridor-cursor-hooks/) | Get real-time feedback on code implementation and security design decisions as code is being written. |
|
||||
| [Semgrep](https://semgrep.dev/blog/2025/cursor-hooks-mcp-server) | Automatically scan AI-generated code for vulnerabilities with real-time feedback to regenerate code until security issues are resolved. |
|
||||
|
||||
### Dependency security
|
||||
|
||||
| Partner | Description |
|
||||
|---------|-------------|
|
||||
| [Endor Labs](https://www.endorlabs.com/learn/bringing-malware-detection-into-ai-coding-workflows-with-cursor-hooks) | Intercept package installations and scan for malicious dependencies, preventing supply chain attacks before they enter your codebase. |
|
||||
|
||||
### Agent security and safety
|
||||
|
||||
| Partner | Description |
|
||||
|---------|-------------|
|
||||
| [Snyk](https://snyk.io/blog/evo-agent-guard-cursor-integration/) | Review agent actions in real-time with Evo Agent Guard, detecting and preventing issues like prompt injection and dangerous tool calls. |
|
||||
|
||||
### Secrets management
|
||||
|
||||
| Partner | Description |
|
||||
|---------|-------------|
|
||||
| [1Password](https://marketplace.1password.com/integration/cursor-hooks) | Validate that environment files from 1Password Environments are properly mounted before shell commands execute, enabling just-in-time secrets access without writing credentials to disk. |
|
||||
|
||||
For more details about our hooks partners, see the [Hooks for security and platform teams](/blog/hooks-partners) blog post.
|
||||
|
||||
## Configuration
|
||||
|
||||
Define hooks in a `hooks.json` file. Configuration can exist at multiple levels; higher-priority sources override lower ones:
|
||||
|
||||
```sh
|
||||
~/.cursor/
|
||||
├── hooks.json
|
||||
└── hooks/
|
||||
├── audit.sh
|
||||
└── block-git.sh
|
||||
```
|
||||
|
||||
- **Global** (Enterprise-managed):
|
||||
- macOS: `/Library/Application Support/Cursor/hooks.json`
|
||||
- Linux/WSL: `/etc/cursor/hooks.json`
|
||||
- Windows: `C:\\ProgramData\\Cursor\\hooks.json`
|
||||
- **Project Directory** (Project-specific):
|
||||
- `<project-root>/.cursor/hooks.json`
|
||||
- Project hooks run in any trusted workspace and are checked into version control with your project
|
||||
- **Home Directory** (User-specific):
|
||||
- `~/.cursor/hooks.json`
|
||||
|
||||
Priority order (highest to lowest): Enterprise → Project → User
|
||||
|
||||
The `hooks` object maps hook names to arrays of hook definitions. Each definition currently supports a `command` property that can be a shell string, an absolute path, or a path relative to the `hooks.json` file.
|
||||
|
||||
### Configuration file
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"beforeShellExecution": [{ "command": "./script.sh" }],
|
||||
"afterShellExecution": [{ "command": "./script.sh" }],
|
||||
"afterMCPExecution": [{ "command": "./script.sh" }],
|
||||
"afterFileEdit": [{ "command": "./format.sh" }],
|
||||
"beforeTabFileRead": [{ "command": "./redact-secrets-tab.sh" }],
|
||||
"afterTabFileEdit": [{ "command": "./format-tab.sh" }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Agent hooks (`beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `beforeReadFile`, `afterFileEdit`, `beforeSubmitPrompt`, `stop`, `afterAgentResponse`, `afterAgentThought`) apply to Cmd+K and Agent Chat operations. The Tab hooks (`beforeTabFileRead`, `afterTabFileEdit`) apply specifically to inline Tab completions.
|
||||
|
||||
## Team Distribution
|
||||
|
||||
Hooks can be distributed to team members using project hooks (via version control), MDM tools, or Cursor's cloud distribution system.
|
||||
|
||||
### Project Hooks (Version Control)
|
||||
|
||||
Project hooks are the simplest way to share hooks with your team. Place a `hooks.json` file at `<project-root>/.cursor/hooks.json` and commit it to your repository. When team members open the project in a trusted workspace, Cursor automatically loads and runs the project hooks.
|
||||
|
||||
Project hooks:
|
||||
- Are stored in version control alongside your code
|
||||
- Automatically load for all team members in trusted workspaces
|
||||
- Can be project-specific (e.g., enforce formatting standards for a particular codebase)
|
||||
- Require the workspace to be trusted to run (for security)
|
||||
|
||||
### MDM Distribution
|
||||
|
||||
Distribute hooks across your organization using Mobile Device Management (MDM) tools. Place the `hooks.json` file and hook scripts in the target directories on each machine.
|
||||
|
||||
**User home directory** (per-user distribution):
|
||||
- `~/.cursor/hooks.json`
|
||||
- `~/.cursor/hooks/` (for hook scripts)
|
||||
|
||||
**Global directories** (system-wide distribution):
|
||||
- macOS: `/Library/Application Support/Cursor/hooks.json`
|
||||
- Linux/WSL: `/etc/cursor/hooks.json`
|
||||
- Windows: `C:\\ProgramData\\Cursor\\hooks.json`
|
||||
|
||||
Note: MDM-based distribution is fully managed by your organization. Cursor does not deploy or manage files through your MDM solution. Ensure your internal IT or security team handles configuration, deployment, and updates in accordance with your organization's policies.
|
||||
|
||||
### Cloud Distribution (Enterprise Only)
|
||||
|
||||
Enterprise teams can use Cursor's native cloud distribution to automatically sync hooks to all team members. Configure hooks in the [web dashboard](https://cursor.com/dashboard?tab=team-content§ion=hooks). Cursor automatically delivers configured hooks to all client machines when team members log in.
|
||||
|
||||
Cloud distribution provides:
|
||||
|
||||
- Automatic synchronization to all team members (every thirty minutes)
|
||||
- Operating system targeting for platform-specific hooks
|
||||
- Centralized management through the dashboard
|
||||
|
||||
Enterprise administrators can create, edit, and manage team hooks from the dashboard without requiring access to individual machines.
|
||||
|
||||
## Reference
|
||||
|
||||
### Common schema
|
||||
|
||||
#### Input (all hooks)
|
||||
|
||||
All hooks receive a base set of fields in addition to their hook-specific fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "string",
|
||||
"generation_id": "string",
|
||||
"model": "string",
|
||||
"hook_event_name": "string",
|
||||
"cursor_version": "string",
|
||||
"workspace_roots": ["<path>"],
|
||||
"user_email": "string | null"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `conversation_id` | string | Stable ID of the conversation across many turns |
|
||||
| `generation_id` | string | The current generation that changes with every user message |
|
||||
| `model` | string | The model configured for the composer that triggered the hook |
|
||||
| `hook_event_name` | string | Which hook is being run |
|
||||
| `cursor_version` | string | Cursor application version (e.g. "1.7.2") |
|
||||
| `workspace_roots` | string[] | The list of root folders in the workspace (normally just one, but multiroot workspaces can have multiple) |
|
||||
| `user_email` | string \| null | Email address of the authenticated user, if available |
|
||||
|
||||
### Hook events
|
||||
|
||||
#### beforeShellExecution / beforeMCPExecution
|
||||
|
||||
Called before any shell command or MCP tool is executed. Return a permission decision.
|
||||
|
||||
```json
|
||||
// beforeShellExecution input
|
||||
{
|
||||
"command": "<full terminal command>",
|
||||
"cwd": "<current working directory>"
|
||||
}
|
||||
|
||||
// beforeMCPExecution input
|
||||
{
|
||||
"tool_name": "<tool name>",
|
||||
"tool_input": "<json params>"
|
||||
}
|
||||
// Plus either:
|
||||
{ "url": "<server url>" }
|
||||
// Or:
|
||||
{ "command": "<command string>" }
|
||||
|
||||
// Output
|
||||
{
|
||||
"permission": "allow" | "deny" | "ask",
|
||||
"user_message": "<message shown in client>",
|
||||
"agent_message": "<message sent to agent>"
|
||||
}
|
||||
```
|
||||
|
||||
#### afterShellExecution
|
||||
|
||||
Fires after a shell command executes; useful for auditing or collecting metrics from command output.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"command": "<full terminal command>",
|
||||
"output": "<full terminal output>",
|
||||
"duration": 1234
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `command` | string | The full terminal command that was executed |
|
||||
| `output` | string | Full output captured from the terminal |
|
||||
| `duration` | number | Duration in milliseconds spent executing the shell command (excludes approval wait time) |
|
||||
|
||||
#### afterMCPExecution
|
||||
|
||||
Fires after an MCP tool executes; includes the tool's input parameters and full JSON result.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"tool_name": "<tool name>",
|
||||
"tool_input": "<json params>",
|
||||
"result_json": "<tool result json>",
|
||||
"duration": 1234
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `tool_name` | string | Name of the MCP tool that was executed |
|
||||
| `tool_input` | string | JSON params string passed to the tool |
|
||||
| `result_json` | string | JSON string of the tool response |
|
||||
| `duration` | number | Duration in milliseconds spent executing the MCP tool (excludes approval wait time) |
|
||||
|
||||
#### afterFileEdit
|
||||
|
||||
Fires after the Agent edits a file; useful for formatters or accounting of agent-written code.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"file_path": "<absolute path>",
|
||||
"edits": [{ "old_string": "<search>", "new_string": "<replace>" }]
|
||||
}
|
||||
```
|
||||
|
||||
#### beforeTabFileRead
|
||||
|
||||
Called before Tab (inline completions) reads a file. Enable redaction or access control before Tab accesses file contents.
|
||||
|
||||
**Key differences from `beforeReadFile`:**
|
||||
- Only triggered by Tab, not Agent
|
||||
- Does not include `attachments` field (Tab doesn't use prompt attachments)
|
||||
- Useful for applying different policies to autonomous Tab operations
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"file_path": "<absolute path>",
|
||||
"content": "<file contents>"
|
||||
}
|
||||
|
||||
// Output
|
||||
{
|
||||
"permission": "allow" | "deny"
|
||||
}
|
||||
```
|
||||
|
||||
#### afterTabFileEdit
|
||||
|
||||
Called after Tab (inline completions) edits a file. Useful for formatters or auditing of Tab-written code.
|
||||
|
||||
**Key differences from `afterFileEdit`:**
|
||||
- Only triggered by Tab, not Agent
|
||||
- Includes detailed edit information: `range`, `old_line`, and `new_line` for precise edit tracking
|
||||
- Useful for fine-grained formatting or analysis of Tab edits
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"file_path": "<absolute path>",
|
||||
"edits": [
|
||||
{
|
||||
"old_string": "<search>",
|
||||
"new_string": "<replace>",
|
||||
"range": {
|
||||
"start_line_number": 10,
|
||||
"start_column": 5,
|
||||
"end_line_number": 10,
|
||||
"end_column": 20
|
||||
},
|
||||
"old_line": "<line before edit>",
|
||||
"new_line": "<line after edit>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Output
|
||||
{
|
||||
// No output fields currently supported
|
||||
}
|
||||
```
|
||||
|
||||
#### beforeSubmitPrompt
|
||||
|
||||
Called right after user hits send but before backend request. Can prevent submission.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"prompt": "<user prompt text>",
|
||||
"attachments": [
|
||||
{
|
||||
"type": "file" | "rule",
|
||||
"filePath": "<absolute path>"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Output
|
||||
{
|
||||
"continue": true | false,
|
||||
"user_message": "<message shown to user when blocked>"
|
||||
}
|
||||
```
|
||||
|
||||
| Output Field | Type | Description |
|
||||
|--------------|------|-------------|
|
||||
| `continue` | boolean | Whether to allow the prompt submission to proceed |
|
||||
| `user_message` | string (optional) | Message shown to the user when the prompt is blocked |
|
||||
|
||||
#### afterAgentResponse
|
||||
|
||||
Called after the agent has completed an assistant message.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"text": "<assistant final text>"
|
||||
}
|
||||
```
|
||||
|
||||
#### afterAgentThought
|
||||
|
||||
Called after the agent completes a thinking block. Useful for observing the agent's reasoning process.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"text": "<fully aggregated thinking text>",
|
||||
"duration_ms": 5000
|
||||
}
|
||||
|
||||
// Output
|
||||
{
|
||||
// No output fields currently supported
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `text` | string | Fully aggregated thinking text for the completed block |
|
||||
| `duration_ms` | number (optional) | Duration in milliseconds for the thinking block |
|
||||
|
||||
#### stop
|
||||
|
||||
Called when the agent loop ends. Can optionally auto-submit a follow-up user message to keep iterating.
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"status": "completed" | "aborted" | "error",
|
||||
"loop_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// Output
|
||||
{
|
||||
"followup_message": "<message text>"
|
||||
}
|
||||
```
|
||||
|
||||
- The optional `followup_message` is a string. When provided and non-empty, Cursor will automatically submit it as the next user message. This enables loop-style flows (e.g., iterate until a goal is met).
|
||||
- The `loop_count` field indicates how many times the stop hook has already triggered an automatic follow-up for this conversation (starts at 0). To prevent infinite loops, a maximum of 5 auto follow-ups is enforced.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**How to confirm hooks are active**
|
||||
|
||||
There is a Hooks tab in Cursor Settings to debug configured and executed hooks, as well as a Hooks output channel to see errors.
|
||||
|
||||
**If hooks are not working**
|
||||
|
||||
- Restart Cursor to ensure the hooks service is running.
|
||||
- Ensure hook script paths are relative to `hooks.json` when using relative paths.
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Cursor + Gemini Setup"
|
||||
description: "Use Claude-Mem in Cursor with Google's free Gemini API"
|
||||
---
|
||||
|
||||
# Cursor + Gemini Setup
|
||||
|
||||
This guide walks you through setting up Claude-Mem in Cursor using Google's Gemini API. Gemini offers a generous free tier that handles typical individual usage.
|
||||
|
||||
<Info>
|
||||
**Free Tier:** 1,500 requests per day with `gemini-2.5-flash-lite`. No credit card required.
|
||||
</Info>
|
||||
|
||||
## Step 1: Get a Gemini API Key
|
||||
|
||||
1. Go to [Google AI Studio](https://aistudio.google.com/apikey)
|
||||
2. Sign in with your Google account
|
||||
3. Accept the Terms of Service
|
||||
4. Click **Create API key**
|
||||
5. Choose or create a Google Cloud project
|
||||
6. Copy your API key - you'll need it in Step 3
|
||||
|
||||
<Tip>
|
||||
**Higher rate limits:** Enable billing on your Google Cloud project to unlock 4,000 RPM (vs 10 RPM without billing). You won't be charged unless you exceed the free quota.
|
||||
</Tip>
|
||||
|
||||
## Step 2: Clone and Build Claude-Mem
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build the project
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Step 3: Configure Gemini Provider
|
||||
|
||||
### Option A: Interactive Setup (Recommended)
|
||||
|
||||
Run the setup wizard which guides you through everything:
|
||||
|
||||
```bash
|
||||
bun run cursor:setup
|
||||
```
|
||||
|
||||
The wizard will:
|
||||
1. Detect you don't have Claude Code
|
||||
2. Ask you to choose Gemini as your provider
|
||||
3. Prompt for your API key
|
||||
4. Install hooks automatically
|
||||
5. Start the worker
|
||||
|
||||
### Option B: Manual Configuration
|
||||
|
||||
Create the settings file manually:
|
||||
|
||||
```bash
|
||||
# Create settings directory
|
||||
mkdir -p ~/.claude-mem
|
||||
|
||||
# Create settings file with Gemini configuration
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_GEMINI_API_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Replace `YOUR_GEMINI_API_KEY` with your actual API key.
|
||||
|
||||
Then install hooks and start the worker:
|
||||
|
||||
```bash
|
||||
bun run cursor:install
|
||||
bun run worker:start
|
||||
```
|
||||
|
||||
## Step 4: Restart Cursor
|
||||
|
||||
Close and reopen Cursor IDE for the hooks to take effect.
|
||||
|
||||
## Step 5: Verify Installation
|
||||
|
||||
```bash
|
||||
# Check worker is running
|
||||
bun run worker:status
|
||||
|
||||
# Check hooks are installed
|
||||
bun run cursor:status
|
||||
```
|
||||
|
||||
Open http://localhost:37777 to see the memory viewer.
|
||||
|
||||
## Available Gemini Models
|
||||
|
||||
| Model | Free Tier RPM | Notes |
|
||||
|-------|---------------|-------|
|
||||
| `gemini-2.5-flash-lite` | 10 (4,000 with billing) | **Default.** Fastest, highest free tier RPM |
|
||||
| `gemini-2.5-flash` | 5 (1,000 with billing) | Higher capability |
|
||||
| `gemini-3-flash` | 5 (1,000 with billing) | Latest model |
|
||||
|
||||
To change the model, update your settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "your-key",
|
||||
"CLAUDE_MEM_GEMINI_MODEL": "gemini-2.5-flash"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Claude-mem automatically handles rate limiting for free tier usage:
|
||||
|
||||
- Requests are spaced to stay within limits
|
||||
- Processing may be slightly slower but stays within quota
|
||||
- No errors or lost observations
|
||||
|
||||
**To remove rate limiting:** Enable billing on your Google Cloud project, then add to settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_GEMINI_BILLING_ENABLED": true
|
||||
}
|
||||
```
|
||||
|
||||
You'll still use the free quota but with much higher rate limits.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Gemini API key not configured"
|
||||
|
||||
Ensure your settings file exists and has the correct format:
|
||||
|
||||
```bash
|
||||
cat ~/.claude-mem/settings.json
|
||||
```
|
||||
|
||||
Should output something like:
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||
"CLAUDE_MEM_GEMINI_API_KEY": "AIza..."
|
||||
}
|
||||
```
|
||||
|
||||
### Rate limit errors (HTTP 429)
|
||||
|
||||
You're exceeding the free tier limits. Options:
|
||||
1. Wait a few minutes for the rate limit to reset
|
||||
2. Enable billing on Google Cloud to unlock higher limits
|
||||
3. Switch to OpenRouter for higher volume needs
|
||||
|
||||
### API key invalid
|
||||
|
||||
1. Verify your key at [Google AI Studio](https://aistudio.google.com/apikey)
|
||||
2. Ensure there are no extra spaces or newlines in your settings.json
|
||||
3. Try generating a new API key
|
||||
|
||||
### Worker not processing observations
|
||||
|
||||
Check the worker logs:
|
||||
|
||||
```bash
|
||||
bun run worker:logs
|
||||
```
|
||||
|
||||
Look for error messages related to Gemini API calls.
|
||||
|
||||
## Switching Providers Later
|
||||
|
||||
You can switch between Gemini, OpenRouter, and Claude SDK at any time by updating your settings. No restart required - changes take effect on the next observation.
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Cursor Integration Overview](/cursor/index) - All Cursor features
|
||||
- [OpenRouter Setup](/cursor/openrouter-setup) - Alternative provider with 100+ models
|
||||
- [Configuration Reference](../configuration) - All settings options
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "Cursor Integration"
|
||||
description: "Persistent AI memory for Cursor IDE - free tier options available"
|
||||
---
|
||||
|
||||
# Cursor Integration
|
||||
|
||||
> **Your AI stops forgetting. Give Cursor persistent memory.**
|
||||
|
||||
Every Cursor session starts fresh - your AI doesn't remember what it worked on yesterday. Claude-mem changes that. Your agent builds cumulative knowledge about your codebase, decisions, and patterns over time.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Free to Start" icon="dollar-sign">
|
||||
Works with Gemini's free tier (1500 req/day) - no subscription required
|
||||
</Card>
|
||||
<Card title="Automatic Capture" icon="bolt">
|
||||
MCP tools, shell commands, and file edits logged without effort
|
||||
</Card>
|
||||
<Card title="Smart Context" icon="brain">
|
||||
Relevant history injected into every chat session
|
||||
</Card>
|
||||
<Card title="Works Everywhere" icon="check">
|
||||
With or without Claude Code subscription
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Info>
|
||||
**No Claude Code subscription required.** Use Gemini (free tier) or OpenRouter as your AI provider.
|
||||
</Info>
|
||||
|
||||
## How It Works
|
||||
|
||||
Claude-mem integrates with Cursor through native hooks:
|
||||
|
||||
1. **Session hooks** capture tool usage, file edits, and shell commands
|
||||
2. **AI extraction** compresses observations into semantic summaries
|
||||
3. **Context injection** loads relevant history into each new session
|
||||
4. **Memory viewer** at http://localhost:37777 shows your knowledge base
|
||||
|
||||
## Installation Paths
|
||||
|
||||
Choose the installation method that fits your setup:
|
||||
|
||||
### Path A: Cursor-Only Users (No Claude Code)
|
||||
|
||||
If you're using Cursor without a Claude Code subscription:
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem && bun install && bun run build
|
||||
|
||||
# Run interactive setup wizard
|
||||
bun run cursor:setup
|
||||
```
|
||||
|
||||
The setup wizard will:
|
||||
- Detect you don't have Claude Code
|
||||
- Help you choose and configure a free AI provider (Gemini recommended)
|
||||
- Install hooks automatically
|
||||
- Start the worker service
|
||||
|
||||
**Detailed guides:**
|
||||
- [Gemini Setup](/cursor/gemini-setup) - Recommended free option (1500 req/day)
|
||||
- [OpenRouter Setup](/cursor/openrouter-setup) - 100+ models including free options
|
||||
|
||||
### Path B: Claude Code Users
|
||||
|
||||
If you have Claude Code installed:
|
||||
|
||||
```bash
|
||||
# Install the plugin (if not already)
|
||||
/plugin marketplace add thedotmack/claude-mem
|
||||
/plugin install claude-mem
|
||||
|
||||
# Install Cursor hooks
|
||||
claude-mem cursor install
|
||||
```
|
||||
|
||||
The plugin uses Claude's SDK by default but you can switch to Gemini or OpenRouter anytime.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="macOS">
|
||||
- [Bun](https://bun.sh): `curl -fsSL https://bun.sh/install | bash`
|
||||
- Cursor IDE
|
||||
- jq and curl: `brew install jq curl`
|
||||
</Accordion>
|
||||
<Accordion title="Linux">
|
||||
- [Bun](https://bun.sh): `curl -fsSL https://bun.sh/install | bash`
|
||||
- Cursor IDE
|
||||
- jq and curl: `apt install jq curl` or `dnf install jq curl`
|
||||
</Accordion>
|
||||
<Accordion title="Windows">
|
||||
- [Bun](https://bun.sh): `powershell -c "irm bun.sh/install.ps1 | iex"`
|
||||
- Cursor IDE
|
||||
- PowerShell 5.1+ (included in Windows 10/11)
|
||||
- Git for Windows
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Quick Commands Reference
|
||||
|
||||
After installation, these commands are available from the claude-mem directory:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `bun run cursor:setup` | Interactive setup wizard |
|
||||
| `bun run cursor:install` | Install Cursor hooks |
|
||||
| `bun run cursor:uninstall` | Remove Cursor hooks |
|
||||
| `bun run cursor:status` | Check hook installation status |
|
||||
| `bun run worker:start` | Start the worker service |
|
||||
| `bun run worker:stop` | Stop the worker service |
|
||||
| `bun run worker:status` | Check worker status |
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
After setup, verify everything is working:
|
||||
|
||||
1. **Check worker status:**
|
||||
```bash
|
||||
bun run worker:status
|
||||
```
|
||||
|
||||
2. **Check hook installation:**
|
||||
```bash
|
||||
bun run cursor:status
|
||||
```
|
||||
|
||||
3. **Open the memory viewer:**
|
||||
Open http://localhost:37777 in your browser
|
||||
|
||||
4. **Restart Cursor** and start a coding session - you should see context being captured
|
||||
|
||||
## Provider Comparison
|
||||
|
||||
| Provider | Cost | Rate Limit | Best For |
|
||||
|----------|------|------------|----------|
|
||||
| Gemini | Free tier | 1500 req/day | Individual use, getting started |
|
||||
| OpenRouter | Pay-per-use + free models | Varies by model | Model variety, high volume |
|
||||
| Claude SDK | Included with Claude Code | Unlimited | Claude Code subscribers |
|
||||
|
||||
<Tip>
|
||||
**Recommendation:** Start with Gemini's free tier. It handles typical individual usage well. Switch to OpenRouter or Claude SDK if you need higher limits.
|
||||
</Tip>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Worker not starting
|
||||
|
||||
```bash
|
||||
# Check if port is in use
|
||||
lsof -i :37777
|
||||
|
||||
# Force restart
|
||||
bun run worker:stop && bun run worker:start
|
||||
|
||||
# Check logs
|
||||
bun run worker:logs
|
||||
```
|
||||
|
||||
### Hooks not firing
|
||||
|
||||
1. Restart Cursor IDE after installation
|
||||
2. Check hooks are installed: `bun run cursor:status`
|
||||
3. Verify hooks.json exists in `.cursor/` directory
|
||||
|
||||
### No context appearing
|
||||
|
||||
1. Ensure worker is running: `bun run worker:status`
|
||||
2. Check that you have observations: visit http://localhost:37777
|
||||
3. Verify your API key is configured correctly
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Gemini Setup Guide](/cursor/gemini-setup) - Detailed free tier setup
|
||||
- [OpenRouter Setup Guide](/cursor/openrouter-setup) - Configure OpenRouter
|
||||
- [Configuration Reference](../configuration) - All settings options
|
||||
- [Troubleshooting](../troubleshooting) - Common issues and solutions
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Cursor + OpenRouter Setup"
|
||||
description: "Use Claude-Mem in Cursor with OpenRouter's 100+ AI models"
|
||||
---
|
||||
|
||||
# Cursor + OpenRouter Setup
|
||||
|
||||
This guide walks you through setting up Claude-Mem in Cursor using OpenRouter. OpenRouter provides access to 100+ AI models from various providers, including several free options.
|
||||
|
||||
<Info>
|
||||
**Model variety:** Access Claude, GPT-4, Gemini, Llama, Mistral, and many more through a single API key.
|
||||
</Info>
|
||||
|
||||
## Step 1: Get an OpenRouter API Key
|
||||
|
||||
1. Go to [OpenRouter](https://openrouter.ai)
|
||||
2. Sign up or sign in
|
||||
3. Navigate to [API Keys](https://openrouter.ai/keys)
|
||||
4. Click **Create Key**
|
||||
5. Copy your API key - you'll need it in Step 3
|
||||
|
||||
<Tip>
|
||||
**Free models available:** OpenRouter offers free versions of several models including Gemini Flash and others. Check the [model list](https://openrouter.ai/models?show_free=true) for current free options.
|
||||
</Tip>
|
||||
|
||||
## Step 2: Clone and Build Claude-Mem
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/thedotmack/claude-mem.git
|
||||
cd claude-mem
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build the project
|
||||
bun run build
|
||||
```
|
||||
|
||||
## Step 3: Configure OpenRouter Provider
|
||||
|
||||
### Option A: Interactive Setup (Recommended)
|
||||
|
||||
Run the setup wizard which guides you through everything:
|
||||
|
||||
```bash
|
||||
bun run cursor:setup
|
||||
```
|
||||
|
||||
When prompted for provider, select **OpenRouter**.
|
||||
|
||||
### Option B: Manual Configuration
|
||||
|
||||
Create the settings file manually:
|
||||
|
||||
```bash
|
||||
# Create settings directory
|
||||
mkdir -p ~/.claude-mem
|
||||
|
||||
# Create settings file with OpenRouter configuration
|
||||
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "YOUR_OPENROUTER_API_KEY"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
Replace `YOUR_OPENROUTER_API_KEY` with your actual API key.
|
||||
|
||||
Then install hooks and start the worker:
|
||||
|
||||
```bash
|
||||
bun run cursor:install
|
||||
bun run worker:start
|
||||
```
|
||||
|
||||
## Step 4: Restart Cursor
|
||||
|
||||
Close and reopen Cursor IDE for the hooks to take effect.
|
||||
|
||||
## Step 5: Verify Installation
|
||||
|
||||
```bash
|
||||
# Check worker is running
|
||||
bun run worker:status
|
||||
|
||||
# Check hooks are installed
|
||||
bun run cursor:status
|
||||
```
|
||||
|
||||
Open http://localhost:37777 to see the memory viewer.
|
||||
|
||||
## Recommended Models
|
||||
|
||||
### Free Models
|
||||
|
||||
| Model | Provider | Notes |
|
||||
|-------|----------|-------|
|
||||
| `google/gemini-2.0-flash-exp:free` | Google | Fast, capable |
|
||||
| `xiaomi/mimo-v2-flash:free` | Xiaomi | Good general purpose |
|
||||
|
||||
### Paid Models (Low Cost)
|
||||
|
||||
| Model | Approx. Cost | Notes |
|
||||
|-------|--------------|-------|
|
||||
| `anthropic/claude-3-haiku` | ~$0.25/1M tokens | Fast, efficient |
|
||||
| `google/gemini-flash-1.5` | ~$0.075/1M tokens | Great value |
|
||||
| `mistralai/mistral-7b-instruct` | ~$0.07/1M tokens | Budget option |
|
||||
|
||||
To specify a model, add to your settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "your-key",
|
||||
"CLAUDE_MEM_OPENROUTER_MODEL": "google/gemini-2.0-flash-exp:free"
|
||||
}
|
||||
```
|
||||
|
||||
## Cost Management
|
||||
|
||||
OpenRouter charges per token. To manage costs:
|
||||
|
||||
1. **Use free models:** Several high-quality free models are available
|
||||
2. **Monitor usage:** Check your [OpenRouter dashboard](https://openrouter.ai/activity)
|
||||
3. **Set spending limits:** Configure limits in OpenRouter settings
|
||||
|
||||
<Warning>
|
||||
**Cost awareness:** Unlike Gemini's free tier, OpenRouter paid models charge per request. Monitor your usage if using paid models.
|
||||
</Warning>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OpenRouter API key not configured"
|
||||
|
||||
Ensure your settings file exists with the correct format:
|
||||
|
||||
```bash
|
||||
cat ~/.claude-mem/settings.json
|
||||
```
|
||||
|
||||
Should output something like:
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "sk-or-..."
|
||||
}
|
||||
```
|
||||
|
||||
### Model not found
|
||||
|
||||
1. Check the model ID is correct at [OpenRouter Models](https://openrouter.ai/models)
|
||||
2. Some models may require payment - check if you have credits
|
||||
3. Free models have `:free` suffix in their ID
|
||||
|
||||
### Rate limits
|
||||
|
||||
OpenRouter rate limits vary by model and your account tier. If you hit limits:
|
||||
1. Wait briefly and retry
|
||||
2. Consider upgrading your OpenRouter account tier
|
||||
3. Switch to a less popular model
|
||||
|
||||
### API errors
|
||||
|
||||
Check the worker logs for details:
|
||||
|
||||
```bash
|
||||
bun run worker:logs
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Invalid API key (regenerate at OpenRouter)
|
||||
- Insufficient credits for paid models
|
||||
- Model temporarily unavailable
|
||||
|
||||
## Switching Providers Later
|
||||
|
||||
You can switch between OpenRouter, Gemini, and Claude SDK at any time by updating your settings. No restart required - changes take effect on the next observation.
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "gemini"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Cursor Integration Overview](/cursor/index) - All Cursor features
|
||||
- [Gemini Setup](/cursor/gemini-setup) - Alternative free provider
|
||||
- [Configuration Reference](../configuration) - All settings options
|
||||
@@ -47,6 +47,15 @@
|
||||
"endless-mode"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Cursor Integration",
|
||||
"icon": "wand-magic-sparkles",
|
||||
"pages": [
|
||||
"cursor/index",
|
||||
"cursor/gemini-setup",
|
||||
"cursor/openrouter-setup"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Best Practices",
|
||||
"icon": "lightbulb",
|
||||
|
||||
@@ -167,6 +167,6 @@ If observations seem lower quality with Gemini:
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](../configuration) - Full settings reference
|
||||
- [Getting Started](getting-started) - Basic usage guide
|
||||
- [Troubleshooting](../troubleshooting) - Common issues
|
||||
- [Configuration](/configuration) - Full settings reference
|
||||
- [Getting Started](/usage/getting-started) - Basic usage guide
|
||||
- [Troubleshooting](/troubleshooting) - Common issues
|
||||
|
||||
@@ -314,7 +314,7 @@ Content-Type: application/json
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](../configuration) - Full settings reference
|
||||
- [Gemini Provider](gemini-provider) - Alternative free provider
|
||||
- [Getting Started](getting-started) - Basic usage guide
|
||||
- [Troubleshooting](../troubleshooting) - Common issues
|
||||
- [Configuration](/configuration) - Full settings reference
|
||||
- [Gemini Provider](/usage/gemini-provider) - Alternative free provider
|
||||
- [Getting Started](/usage/getting-started) - Basic usage guide
|
||||
- [Troubleshooting](/troubleshooting) - Common issues
|
||||
|
||||
+5
-1
@@ -53,7 +53,11 @@
|
||||
"translate:tier3": "npm run translate-readme -- vi id th hi bn ro sv",
|
||||
"translate:tier4": "npm run translate-readme -- it el hu fi da no",
|
||||
"translate:all": "npm run translate:tier1 & npm run translate:tier2 & npm run translate:tier3 & npm run translate:tier4 & wait",
|
||||
"bug-report": "npx tsx scripts/bug-report/cli.ts"
|
||||
"bug-report": "npx tsx scripts/bug-report/cli.ts",
|
||||
"cursor:install": "bun plugin/scripts/worker-service.cjs cursor install",
|
||||
"cursor:uninstall": "bun plugin/scripts/worker-service.cjs cursor uninstall",
|
||||
"cursor:status": "bun plugin/scripts/worker-service.cjs cursor status",
|
||||
"cursor:setup": "bun plugin/scripts/worker-service.cjs cursor setup"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -197,6 +197,7 @@ async function buildHooks() {
|
||||
console.log(` - Worker: worker-service.cjs`);
|
||||
console.log(` - MCP Server: mcp-server.cjs`);
|
||||
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
|
||||
console.log('📝 Cursor hooks are in cursor-hooks/ (no build needed - plain shell scripts)');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Build failed:', error.message);
|
||||
|
||||
@@ -17,7 +17,14 @@ import { logger } from '../utils/logger.js';
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import * as readline from 'readline';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
readCursorRegistry as readCursorRegistryFromFile,
|
||||
writeCursorRegistry as writeCursorRegistryToFile,
|
||||
writeContextFile,
|
||||
type CursorProjectRegistry
|
||||
} from '../utils/cursor-utils.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -30,6 +37,7 @@ const BUILT_IN_VERSION = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined'
|
||||
// PID file management for self-spawn pattern
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
const CURSOR_REGISTRY_FILE = path.join(DATA_DIR, 'cursor-projects.json');
|
||||
const HOOK_RESPONSE = '{"continue": true, "suppressOutput": true}';
|
||||
|
||||
interface PidInfo {
|
||||
@@ -62,6 +70,68 @@ function removePidFile(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cursor Project Registry
|
||||
// Tracks which projects have Cursor hooks installed for auto-context updates
|
||||
// Uses pure functions from cursor-utils.ts for testability
|
||||
// ============================================================================
|
||||
|
||||
function readCursorRegistry(): CursorProjectRegistry {
|
||||
return readCursorRegistryFromFile(CURSOR_REGISTRY_FILE);
|
||||
}
|
||||
|
||||
function writeCursorRegistry(registry: CursorProjectRegistry): void {
|
||||
writeCursorRegistryToFile(CURSOR_REGISTRY_FILE, registry);
|
||||
}
|
||||
|
||||
function registerCursorProject(projectName: string, workspacePath: string): void {
|
||||
const registry = readCursorRegistry();
|
||||
registry[projectName] = {
|
||||
workspacePath,
|
||||
installedAt: new Date().toISOString()
|
||||
};
|
||||
writeCursorRegistry(registry);
|
||||
logger.info('CURSOR', 'Registered project for auto-context updates', { projectName, workspacePath });
|
||||
}
|
||||
|
||||
function unregisterCursorProject(projectName: string): void {
|
||||
const registry = readCursorRegistry();
|
||||
if (registry[projectName]) {
|
||||
delete registry[projectName];
|
||||
writeCursorRegistry(registry);
|
||||
logger.info('CURSOR', 'Unregistered project', { projectName });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Cursor context files for all registered projects matching this project name.
|
||||
* Called by SDK agents after saving a summary.
|
||||
*/
|
||||
export async function updateCursorContextForProject(projectName: string, port: number): Promise<void> {
|
||||
const registry = readCursorRegistry();
|
||||
const entry = registry[projectName];
|
||||
|
||||
if (!entry) return; // Project doesn't have Cursor hooks installed
|
||||
|
||||
try {
|
||||
// Fetch fresh context from worker
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const context = await response.text();
|
||||
if (!context || !context.trim()) return;
|
||||
|
||||
// Write to the project's Cursor rules file using shared utility
|
||||
writeContextFile(entry.workspacePath, context);
|
||||
logger.debug('CURSOR', 'Updated context file', { projectName, workspacePath: entry.workspacePath });
|
||||
} catch (error) {
|
||||
logger.warn('CURSOR', 'Failed to update context file', { projectName, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// No lock file needed - health checks and port binding provide coordination
|
||||
|
||||
/**
|
||||
@@ -973,6 +1043,830 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cursor Hooks Installation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Interactive setup wizard for Cursor users
|
||||
* Guides through provider selection and API key configuration
|
||||
*/
|
||||
async function runInteractiveSetup(): Promise<number> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const question = (prompt: string): Promise<string> => {
|
||||
return new Promise(resolve => rl.question(prompt, resolve));
|
||||
};
|
||||
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ Claude-Mem Cursor Setup Wizard ║
|
||||
║ ║
|
||||
║ This wizard will guide you through setting up claude-mem ║
|
||||
║ for use with Cursor IDE. ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
try {
|
||||
// Step 1: Check environment
|
||||
console.log('Step 1: Checking environment...\n');
|
||||
|
||||
const hasClaudeCode = await detectClaudeCode();
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
let settings: Record<string, unknown> = {};
|
||||
|
||||
// Load existing settings if present
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
}
|
||||
}
|
||||
|
||||
const currentProvider = settings['CLAUDE_MEM_PROVIDER'] as string || (hasClaudeCode ? 'claude-sdk' : 'none');
|
||||
|
||||
if (hasClaudeCode) {
|
||||
console.log('✅ Claude Code detected\n');
|
||||
} else {
|
||||
console.log('ℹ️ Claude Code not detected\n');
|
||||
}
|
||||
|
||||
console.log(`Current provider: ${currentProvider}\n`);
|
||||
|
||||
// Step 2: Provider selection (always show)
|
||||
console.log('Step 2: Choose AI Provider\n');
|
||||
if (hasClaudeCode) {
|
||||
console.log(' [1] Claude SDK (Recommended - uses your Claude Code subscription)');
|
||||
} else {
|
||||
console.log(' [1] Claude SDK (requires Claude Code subscription)');
|
||||
}
|
||||
console.log(' [2] Gemini (1500 free requests/day)');
|
||||
console.log(' [3] OpenRouter (100+ models, some free)');
|
||||
console.log(' [4] Keep current settings\n');
|
||||
|
||||
const providerChoice = await question('Enter choice [1-4]: ');
|
||||
|
||||
if (providerChoice === '1') {
|
||||
settings['CLAUDE_MEM_PROVIDER'] = 'claude-sdk';
|
||||
mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log('\n✅ Claude SDK configured!\n');
|
||||
} else if (providerChoice === '2') {
|
||||
console.log('\n📝 Configuring Gemini...\n');
|
||||
console.log(' Get your free API key at: https://aistudio.google.com/apikey\n');
|
||||
|
||||
const apiKey = await question('Enter your Gemini API key: ');
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
console.log('\n⚠️ No API key provided. You can add it later in ~/.claude-mem/settings.json\n');
|
||||
} else {
|
||||
settings['CLAUDE_MEM_PROVIDER'] = 'gemini';
|
||||
settings['CLAUDE_MEM_GEMINI_API_KEY'] = apiKey.trim();
|
||||
|
||||
mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log('\n✅ Gemini configured successfully!\n');
|
||||
}
|
||||
} else if (providerChoice === '3') {
|
||||
console.log('\n📝 Configuring OpenRouter...\n');
|
||||
console.log(' Get your API key at: https://openrouter.ai/keys\n');
|
||||
|
||||
const apiKey = await question('Enter your OpenRouter API key: ');
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
console.log('\n⚠️ No API key provided. You can add it later in ~/.claude-mem/settings.json\n');
|
||||
} else {
|
||||
settings['CLAUDE_MEM_PROVIDER'] = 'openrouter';
|
||||
settings['CLAUDE_MEM_OPENROUTER_API_KEY'] = apiKey.trim();
|
||||
|
||||
mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log('\n✅ OpenRouter configured successfully!\n');
|
||||
}
|
||||
} else {
|
||||
console.log('\n✅ Keeping current settings.\n');
|
||||
}
|
||||
|
||||
// Step 3: Install location
|
||||
console.log('Step 3: Choose installation scope\n');
|
||||
console.log(' [1] Project (current directory only) - Recommended');
|
||||
console.log(' [2] User (all projects for current user)');
|
||||
console.log(' [3] Skip hook installation\n');
|
||||
|
||||
const scopeChoice = await question('Enter choice [1-3]: ');
|
||||
|
||||
let installTarget: string | null = null;
|
||||
if (scopeChoice === '1') {
|
||||
installTarget = 'project';
|
||||
} else if (scopeChoice === '2') {
|
||||
installTarget = 'user';
|
||||
} else {
|
||||
console.log('\n⚠️ Skipping hook installation.\n');
|
||||
}
|
||||
|
||||
// Step 4: Install hooks (if target selected)
|
||||
if (installTarget) {
|
||||
console.log(`Step 4: Installing Cursor hooks (${installTarget})...\n`);
|
||||
|
||||
const cursorHooksDir = findCursorHooksDir();
|
||||
if (!cursorHooksDir) {
|
||||
console.error('❌ Could not find cursor-hooks directory');
|
||||
console.error(' Make sure you ran npm run build first.');
|
||||
rl.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
const installResult = await installCursorHooks(cursorHooksDir, installTarget);
|
||||
|
||||
if (installResult !== 0) {
|
||||
rl.close();
|
||||
return installResult;
|
||||
}
|
||||
|
||||
// Step 5: Configure MCP server for memory search
|
||||
console.log('\nStep 5: Configuring MCP server for memory search...\n');
|
||||
|
||||
const mcpResult = configureCursorMcp(installTarget);
|
||||
if (mcpResult !== 0) {
|
||||
console.warn('⚠️ MCP configuration failed, but hooks are installed.');
|
||||
console.warn(' You can manually configure MCP later.\n');
|
||||
} else {
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Start worker
|
||||
console.log('\nStep 6: Starting claude-mem worker...\n');
|
||||
|
||||
const port = getWorkerPort();
|
||||
const alreadyRunning = await waitForHealth(port, 1000);
|
||||
|
||||
if (alreadyRunning) {
|
||||
console.log('✅ Worker is already running!\n');
|
||||
} else {
|
||||
console.log(' Starting worker in background...');
|
||||
|
||||
// Spawn worker daemon
|
||||
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
console.error('❌ Failed to start worker');
|
||||
rl.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
child.unref();
|
||||
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
|
||||
|
||||
// Wait for health
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
console.error('❌ Worker failed to start');
|
||||
rl.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log('✅ Worker started successfully!\n');
|
||||
}
|
||||
|
||||
// Final summary
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════════╗
|
||||
║ Setup Complete! 🎉 ║
|
||||
╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
What's installed:
|
||||
✓ Cursor hooks - Automatically capture sessions
|
||||
✓ Context injection - Past work injected into new chats
|
||||
✓ MCP search server - Ask "what did I work on last week?"
|
||||
|
||||
Next steps:
|
||||
1. Restart Cursor to load the hooks and MCP server
|
||||
2. Start chatting - your sessions will be remembered!
|
||||
3. Use natural language to search: "find where I fixed the auth bug"
|
||||
|
||||
Useful commands:
|
||||
npm run cursor:status Check installation status
|
||||
npm run worker:status Check worker status
|
||||
npm run worker:logs View worker logs
|
||||
|
||||
Memory viewer:
|
||||
http://localhost:${port}
|
||||
|
||||
Documentation:
|
||||
https://docs.claude-mem.ai/cursor
|
||||
`);
|
||||
|
||||
rl.close();
|
||||
return 0;
|
||||
} catch (error) {
|
||||
rl.close();
|
||||
console.error(`\n❌ Setup failed: ${(error as Error).message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if Claude Code is available
|
||||
* Checks for the Claude Code CLI and plugin directory
|
||||
*/
|
||||
async function detectClaudeCode(): Promise<boolean> {
|
||||
try {
|
||||
// Check for Claude Code CLI
|
||||
const { stdout } = await execAsync('which claude || where claude', { timeout: 5000 });
|
||||
if (stdout.trim()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// CLI not found
|
||||
}
|
||||
|
||||
// Check for Claude Code plugin directory
|
||||
const pluginDir = path.join(homedir(), '.claude', 'plugins');
|
||||
if (existsSync(pluginDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find cursor-hooks directory
|
||||
* Searches in order: marketplace install, source repo
|
||||
* Checks for both bash (common.sh) and PowerShell (common.ps1) scripts
|
||||
*/
|
||||
function findCursorHooksDir(): string | null {
|
||||
const possiblePaths = [
|
||||
// Marketplace install location
|
||||
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'cursor-hooks'),
|
||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
||||
path.join(path.dirname(__filename), '..', '..', 'cursor-hooks'),
|
||||
// Alternative dev location
|
||||
path.join(process.cwd(), 'cursor-hooks'),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
// Check for either bash or PowerShell common script
|
||||
if (existsSync(path.join(p, 'common.sh')) || existsSync(path.join(p, 'common.ps1'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find MCP server script path
|
||||
* Searches in order: marketplace install, source repo
|
||||
*/
|
||||
function findMcpServerPath(): string | null {
|
||||
const possiblePaths = [
|
||||
// Marketplace install location
|
||||
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
||||
path.join(path.dirname(__filename), 'mcp-server.cjs'),
|
||||
// Alternative dev location
|
||||
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CursorMcpConfig {
|
||||
mcpServers: {
|
||||
[name: string]: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure MCP server in Cursor's mcp.json
|
||||
* @param target 'project' or 'user'
|
||||
* @returns 0 on success, 1 on failure
|
||||
*/
|
||||
function configureCursorMcp(target: string): number {
|
||||
const mcpServerPath = findMcpServerPath();
|
||||
|
||||
if (!mcpServerPath) {
|
||||
console.error('❌ Could not find MCP server script');
|
||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
||||
return 1;
|
||||
}
|
||||
|
||||
let mcpJsonDir: string;
|
||||
let mcpJsonPath: string;
|
||||
|
||||
switch (target) {
|
||||
case 'project':
|
||||
mcpJsonDir = path.join(process.cwd(), '.cursor');
|
||||
mcpJsonPath = path.join(mcpJsonDir, 'mcp.json');
|
||||
break;
|
||||
case 'user':
|
||||
mcpJsonDir = path.join(homedir(), '.cursor');
|
||||
mcpJsonPath = path.join(mcpJsonDir, 'mcp.json');
|
||||
break;
|
||||
default:
|
||||
console.error(`❌ Invalid target: ${target}. Use: project or user`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create directory if needed
|
||||
mkdirSync(mcpJsonDir, { recursive: true });
|
||||
|
||||
// Load existing config or create new
|
||||
let config: CursorMcpConfig = { mcpServers: {} };
|
||||
if (existsSync(mcpJsonPath)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
config = { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Add claude-mem MCP server
|
||||
config.mcpServers['claude-mem'] = {
|
||||
command: 'node',
|
||||
args: [mcpServerPath]
|
||||
};
|
||||
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
|
||||
console.log(` ✓ Configured MCP server in ${target === 'user' ? '~/.cursor' : '.cursor'}/mcp.json`);
|
||||
console.log(` Server path: ${mcpServerPath}`);
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to configure MCP: ${(error as Error).message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cursor subcommand for hooks installation
|
||||
*/
|
||||
async function handleCursorCommand(subcommand: string, args: string[]): Promise<number> {
|
||||
switch (subcommand) {
|
||||
case 'install': {
|
||||
const target = args[0] || 'project';
|
||||
const cursorHooksDir = findCursorHooksDir();
|
||||
|
||||
if (!cursorHooksDir) {
|
||||
console.error('❌ Could not find cursor-hooks directory');
|
||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/cursor-hooks/');
|
||||
return 1;
|
||||
}
|
||||
|
||||
return installCursorHooks(cursorHooksDir, target);
|
||||
}
|
||||
|
||||
case 'uninstall': {
|
||||
const target = args[0] || 'project';
|
||||
return uninstallCursorHooks(target);
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
return checkCursorHooksStatus();
|
||||
}
|
||||
|
||||
case 'setup': {
|
||||
// Interactive guided setup for Cursor users
|
||||
return await runInteractiveSetup();
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`
|
||||
Claude-Mem Cursor Integration
|
||||
|
||||
Usage: claude-mem cursor <command> [options]
|
||||
|
||||
Commands:
|
||||
setup Interactive guided setup (recommended for first-time users)
|
||||
|
||||
install [target] Install Cursor hooks
|
||||
target: project (default), user, or enterprise
|
||||
|
||||
uninstall [target] Remove Cursor hooks
|
||||
target: project (default), user, or enterprise
|
||||
|
||||
status Check installation status
|
||||
|
||||
Examples:
|
||||
npm run cursor:setup # Interactive wizard (recommended)
|
||||
npm run cursor:install # Install for current project
|
||||
claude-mem cursor install user # Install globally for user
|
||||
claude-mem cursor uninstall # Remove from current project
|
||||
claude-mem cursor status # Check if hooks are installed
|
||||
|
||||
For more info: https://docs.claude-mem.ai/cursor
|
||||
`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform for script selection
|
||||
*/
|
||||
function detectPlatform(): 'windows' | 'unix' {
|
||||
return process.platform === 'win32' ? 'windows' : 'unix';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get script extension based on platform
|
||||
*/
|
||||
function getScriptExtension(): string {
|
||||
return detectPlatform() === 'windows' ? '.ps1' : '.sh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Cursor hooks
|
||||
*/
|
||||
async function installCursorHooks(sourceDir: string, target: string): Promise<number> {
|
||||
const platform = detectPlatform();
|
||||
const scriptExt = getScriptExtension();
|
||||
|
||||
console.log(`\n📦 Installing Claude-Mem Cursor hooks (${target} level, ${platform})...\n`);
|
||||
|
||||
let targetDir: string;
|
||||
let hooksDir: string;
|
||||
let workspaceRoot: string = process.cwd();
|
||||
|
||||
switch (target) {
|
||||
case 'project':
|
||||
targetDir = path.join(process.cwd(), '.cursor');
|
||||
hooksDir = path.join(targetDir, 'hooks');
|
||||
break;
|
||||
case 'user':
|
||||
targetDir = path.join(homedir(), '.cursor');
|
||||
hooksDir = path.join(targetDir, 'hooks');
|
||||
break;
|
||||
case 'enterprise':
|
||||
if (process.platform === 'darwin') {
|
||||
targetDir = '/Library/Application Support/Cursor';
|
||||
hooksDir = path.join(targetDir, 'hooks');
|
||||
} else if (process.platform === 'linux') {
|
||||
targetDir = '/etc/cursor';
|
||||
hooksDir = path.join(targetDir, 'hooks');
|
||||
} else if (process.platform === 'win32') {
|
||||
targetDir = path.join(process.env.ProgramData || 'C:\\ProgramData', 'Cursor');
|
||||
hooksDir = path.join(targetDir, 'hooks');
|
||||
} else {
|
||||
console.error('❌ Enterprise installation not supported on this platform');
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error(`❌ Invalid target: ${target}. Use: project, user, or enterprise`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create directories
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
// Determine which scripts to copy based on platform
|
||||
const commonScript = platform === 'windows' ? 'common.ps1' : 'common.sh';
|
||||
const hookScripts = [
|
||||
`session-init${scriptExt}`,
|
||||
`context-inject${scriptExt}`,
|
||||
`save-observation${scriptExt}`,
|
||||
`save-file-edit${scriptExt}`,
|
||||
`session-summary${scriptExt}`
|
||||
];
|
||||
|
||||
const scripts = [commonScript, ...hookScripts];
|
||||
|
||||
for (const script of scripts) {
|
||||
const srcPath = path.join(sourceDir, script);
|
||||
const dstPath = path.join(hooksDir, script);
|
||||
|
||||
if (existsSync(srcPath)) {
|
||||
const content = readFileSync(srcPath, 'utf-8');
|
||||
// Unix scripts need execute permission; Windows PowerShell doesn't need it
|
||||
const mode = platform === 'windows' ? undefined : 0o755;
|
||||
writeFileSync(dstPath, content, mode ? { mode } : undefined);
|
||||
console.log(` ✓ Copied ${script}`);
|
||||
} else {
|
||||
console.warn(` ⚠ ${script} not found in source`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate hooks.json with correct paths and platform-appropriate commands
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
const hookPrefix = target === 'project' ? './.cursor/hooks/' : `${hooksDir}/`;
|
||||
|
||||
// For PowerShell, we need to invoke via powershell.exe
|
||||
const makeHookCommand = (scriptName: string) => {
|
||||
const scriptPath = `${hookPrefix}${scriptName}${scriptExt}`;
|
||||
if (platform === 'windows') {
|
||||
// PowerShell execution: use -ExecutionPolicy Bypass to ensure scripts run
|
||||
return `powershell.exe -ExecutionPolicy Bypass -File "${scriptPath}"`;
|
||||
}
|
||||
return scriptPath;
|
||||
};
|
||||
|
||||
const hooksJson = {
|
||||
version: 1,
|
||||
hooks: {
|
||||
beforeSubmitPrompt: [
|
||||
{ command: makeHookCommand('session-init') },
|
||||
{ command: makeHookCommand('context-inject') }
|
||||
],
|
||||
afterMCPExecution: [
|
||||
{ command: makeHookCommand('save-observation') }
|
||||
],
|
||||
afterShellExecution: [
|
||||
{ command: makeHookCommand('save-observation') }
|
||||
],
|
||||
afterFileEdit: [
|
||||
{ command: makeHookCommand('save-file-edit') }
|
||||
],
|
||||
stop: [
|
||||
{ command: makeHookCommand('session-summary') }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
|
||||
console.log(` ✓ Created hooks.json (${platform} mode)`);
|
||||
|
||||
// For project-level: create initial context file
|
||||
if (target === 'project') {
|
||||
const rulesDir = path.join(targetDir, 'rules');
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
// Try to generate initial context from existing memory
|
||||
const port = getWorkerPort();
|
||||
const projectName = path.basename(workspaceRoot);
|
||||
let contextGenerated = false;
|
||||
|
||||
console.log(` ⏳ Generating initial context...`);
|
||||
|
||||
try {
|
||||
// Check if worker is running
|
||||
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||
if (healthResponse.ok) {
|
||||
// Fetch context
|
||||
const contextResponse = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
|
||||
);
|
||||
if (contextResponse.ok) {
|
||||
const context = await contextResponse.text();
|
||||
if (context && context.trim()) {
|
||||
const rulesFile = path.join(rulesDir, 'claude-mem-context.mdc');
|
||||
const contextContent = `---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*This context is updated after each session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
`;
|
||||
writeFileSync(rulesFile, contextContent);
|
||||
contextGenerated = true;
|
||||
console.log(` ✓ Generated initial context from existing memory`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Worker not running - that's ok, context will be generated after first session
|
||||
}
|
||||
|
||||
if (!contextGenerated) {
|
||||
// Create placeholder context file
|
||||
const rulesFile = path.join(rulesDir, 'claude-mem-context.mdc');
|
||||
const placeholderContent = `---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
*No context yet. Complete your first session and context will appear here.*
|
||||
|
||||
Use claude-mem's MCP search tools for manual memory queries.
|
||||
`;
|
||||
writeFileSync(rulesFile, placeholderContent);
|
||||
console.log(` ✓ Created placeholder context file (will populate after first session)`);
|
||||
}
|
||||
|
||||
// Register project for automatic context updates after summaries
|
||||
registerCursorProject(projectName, workspaceRoot);
|
||||
console.log(` ✓ Registered for auto-context updates`);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
✅ Installation complete!
|
||||
|
||||
Hooks installed to: ${targetDir}/hooks.json
|
||||
Scripts installed to: ${hooksDir}
|
||||
|
||||
Next steps:
|
||||
1. Start claude-mem worker: claude-mem start
|
||||
2. Restart Cursor to load the hooks
|
||||
3. Check Cursor Settings → Hooks tab to verify
|
||||
|
||||
Context Injection:
|
||||
Context from past sessions is stored in .cursor/rules/claude-mem-context.mdc
|
||||
and automatically included in every chat. It updates after each session ends.
|
||||
`);
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Installation failed: ${(error as Error).message}`);
|
||||
if (target === 'enterprise') {
|
||||
console.error(' Tip: Enterprise installation may require sudo/admin privileges');
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall Cursor hooks
|
||||
*/
|
||||
function uninstallCursorHooks(target: string): number {
|
||||
console.log(`\n🗑️ Uninstalling Claude-Mem Cursor hooks (${target} level)...\n`);
|
||||
|
||||
let targetDir: string;
|
||||
|
||||
switch (target) {
|
||||
case 'project':
|
||||
targetDir = path.join(process.cwd(), '.cursor');
|
||||
break;
|
||||
case 'user':
|
||||
targetDir = path.join(homedir(), '.cursor');
|
||||
break;
|
||||
case 'enterprise':
|
||||
if (process.platform === 'darwin') {
|
||||
targetDir = '/Library/Application Support/Cursor';
|
||||
} else if (process.platform === 'linux') {
|
||||
targetDir = '/etc/cursor';
|
||||
} else {
|
||||
console.error('❌ Enterprise not supported on Windows');
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error(`❌ Invalid target: ${target}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const hooksDir = path.join(targetDir, 'hooks');
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
|
||||
// Remove hook scripts for both platforms (in case user switches platforms)
|
||||
const bashScripts = ['common.sh', 'session-init.sh', 'context-inject.sh',
|
||||
'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh'];
|
||||
const psScripts = ['common.ps1', 'session-init.ps1', 'context-inject.ps1',
|
||||
'save-observation.ps1', 'save-file-edit.ps1', 'session-summary.ps1'];
|
||||
|
||||
const allScripts = [...bashScripts, ...psScripts];
|
||||
|
||||
for (const script of allScripts) {
|
||||
const scriptPath = path.join(hooksDir, script);
|
||||
if (existsSync(scriptPath)) {
|
||||
unlinkSync(scriptPath);
|
||||
console.log(` ✓ Removed ${script}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove hooks.json
|
||||
if (existsSync(hooksJsonPath)) {
|
||||
unlinkSync(hooksJsonPath);
|
||||
console.log(` ✓ Removed hooks.json`);
|
||||
}
|
||||
|
||||
// Remove context file and unregister if project-level
|
||||
if (target === 'project') {
|
||||
const contextFile = path.join(targetDir, 'rules', 'claude-mem-context.mdc');
|
||||
if (existsSync(contextFile)) {
|
||||
unlinkSync(contextFile);
|
||||
console.log(` ✓ Removed context file`);
|
||||
}
|
||||
|
||||
// Unregister from auto-context updates
|
||||
const projectName = path.basename(process.cwd());
|
||||
unregisterCursorProject(projectName);
|
||||
console.log(` ✓ Unregistered from auto-context updates`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Uninstallation complete!\n`);
|
||||
console.log('Restart Cursor to apply changes.');
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Uninstallation failed: ${(error as Error).message}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Cursor hooks installation status
|
||||
*/
|
||||
function checkCursorHooksStatus(): number {
|
||||
console.log('\n🔍 Claude-Mem Cursor Hooks Status\n');
|
||||
|
||||
const locations = [
|
||||
{ name: 'Project', dir: path.join(process.cwd(), '.cursor') },
|
||||
{ name: 'User', dir: path.join(homedir(), '.cursor') },
|
||||
];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
locations.push({ name: 'Enterprise', dir: '/Library/Application Support/Cursor' });
|
||||
} else if (process.platform === 'linux') {
|
||||
locations.push({ name: 'Enterprise', dir: '/etc/cursor' });
|
||||
}
|
||||
|
||||
let anyInstalled = false;
|
||||
|
||||
for (const loc of locations) {
|
||||
const hooksJson = path.join(loc.dir, 'hooks.json');
|
||||
const hooksDir = path.join(loc.dir, 'hooks');
|
||||
|
||||
if (existsSync(hooksJson)) {
|
||||
anyInstalled = true;
|
||||
console.log(`✅ ${loc.name}: Installed`);
|
||||
console.log(` Config: ${hooksJson}`);
|
||||
|
||||
// Detect which platform's scripts are installed
|
||||
const bashScripts = ['session-init.sh', 'context-inject.sh', 'save-observation.sh'];
|
||||
const psScripts = ['session-init.ps1', 'context-inject.ps1', 'save-observation.ps1'];
|
||||
|
||||
const hasBash = bashScripts.some(s => existsSync(path.join(hooksDir, s)));
|
||||
const hasPs = psScripts.some(s => existsSync(path.join(hooksDir, s)));
|
||||
|
||||
if (hasBash && hasPs) {
|
||||
console.log(` Platform: Both (bash + PowerShell)`);
|
||||
} else if (hasBash) {
|
||||
console.log(` Platform: Unix (bash)`);
|
||||
} else if (hasPs) {
|
||||
console.log(` Platform: Windows (PowerShell)`);
|
||||
} else {
|
||||
console.log(` ⚠ No hook scripts found`);
|
||||
}
|
||||
|
||||
// Check for appropriate scripts based on current platform
|
||||
const platform = detectPlatform();
|
||||
const scripts = platform === 'windows' ? psScripts : bashScripts;
|
||||
const missing = scripts.filter(s => !existsSync(path.join(hooksDir, s)));
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(` ⚠ Missing ${platform} scripts: ${missing.join(', ')}`);
|
||||
} else {
|
||||
console.log(` Scripts: All present for ${platform}`);
|
||||
}
|
||||
|
||||
// Check for context file (project only)
|
||||
if (loc.name === 'Project') {
|
||||
const contextFile = path.join(loc.dir, 'rules', 'claude-mem-context.mdc');
|
||||
if (existsSync(contextFile)) {
|
||||
console.log(` Context: Active`);
|
||||
} else {
|
||||
console.log(` Context: Not yet generated (will be created on first prompt)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ ${loc.name}: Not installed`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!anyInstalled) {
|
||||
console.log('No hooks installed. Run: claude-mem cursor install\n');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Entry Point
|
||||
// ============================================================================
|
||||
@@ -1130,6 +2024,13 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case 'cursor': {
|
||||
// Cursor hooks installation subcommand
|
||||
const subcommand = process.argv[3];
|
||||
const cursorResult = await handleCursorCommand(subcommand, process.argv.slice(4));
|
||||
process.exit(cursorResult);
|
||||
}
|
||||
|
||||
case '--daemon':
|
||||
default: {
|
||||
// Run server directly
|
||||
|
||||
@@ -20,6 +20,8 @@ import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildConti
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { updateCursorContextForProject } from '../worker-service.js';
|
||||
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||
|
||||
// Gemini API endpoint
|
||||
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||
@@ -493,6 +495,9 @@ export class GeminiAgent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update Cursor context file for registered projects (fire-and-forget)
|
||||
updateCursorContextForProject(session.project, getWorkerPort()).catch(() => {});
|
||||
}
|
||||
|
||||
// Mark messages as processed
|
||||
|
||||
@@ -20,6 +20,8 @@ import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { updateCursorContextForProject } from '../worker-service.js';
|
||||
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||
|
||||
// OpenRouter API endpoint
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
@@ -536,6 +538,9 @@ export class OpenRouterAgent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update Cursor context file for registered projects (fire-and-forget)
|
||||
updateCursorContextForProject(session.project, getWorkerPort()).catch(() => {});
|
||||
}
|
||||
|
||||
// Mark messages as processed
|
||||
|
||||
@@ -20,6 +20,8 @@ import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import type { ActiveSession, SDKUserMessage, PendingMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { updateCursorContextForProject } from '../worker-service.js';
|
||||
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||
|
||||
// Import Agent SDK (assumes it's installed)
|
||||
// @ts-ignore - Agent SDK types may not be available
|
||||
@@ -474,6 +476,9 @@ export class SDKAgent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update Cursor context file for registered projects (fire-and-forget)
|
||||
updateCursorContextForProject(session.project, getWorkerPort()).catch(() => {});
|
||||
}
|
||||
|
||||
// Mark messages as processed after successful observation/summary storage
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Cursor Integration Utilities
|
||||
*
|
||||
* Pure functions for Cursor project registry, context files, and MCP configuration.
|
||||
* Designed for testability - all file paths are passed as parameters.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CursorProjectRegistry {
|
||||
[projectName: string]: {
|
||||
workspacePath: string;
|
||||
installedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CursorMcpConfig {
|
||||
mcpServers: {
|
||||
[name: string]: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Registry Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read the Cursor project registry from a file
|
||||
*/
|
||||
export function readCursorRegistry(registryFile: string): CursorProjectRegistry {
|
||||
try {
|
||||
if (!existsSync(registryFile)) return {};
|
||||
return JSON.parse(readFileSync(registryFile, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Cursor project registry to a file
|
||||
*/
|
||||
export function writeCursorRegistry(registryFile: string, registry: CursorProjectRegistry): void {
|
||||
const dir = join(registryFile, '..');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a project in the Cursor registry
|
||||
*/
|
||||
export function registerCursorProject(
|
||||
registryFile: string,
|
||||
projectName: string,
|
||||
workspacePath: string
|
||||
): void {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
registry[projectName] = {
|
||||
workspacePath,
|
||||
installedAt: new Date().toISOString()
|
||||
};
|
||||
writeCursorRegistry(registryFile, registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a project from the Cursor registry
|
||||
*/
|
||||
export function unregisterCursorProject(registryFile: string, projectName: string): void {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
if (registry[projectName]) {
|
||||
delete registry[projectName];
|
||||
writeCursorRegistry(registryFile, registry);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context File Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Write context file to a Cursor project's .cursor/rules directory
|
||||
* Uses atomic write (temp file + rename) to prevent corruption
|
||||
*/
|
||||
export function writeContextFile(workspacePath: string, context: string): void {
|
||||
const rulesDir = join(workspacePath, '.cursor', 'rules');
|
||||
const rulesFile = join(rulesDir, 'claude-mem-context.mdc');
|
||||
const tempFile = `${rulesFile}.tmp`;
|
||||
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
const content = `---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
`;
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
writeFileSync(tempFile, content);
|
||||
renameSync(tempFile, rulesFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context file from a Cursor project's .cursor/rules directory
|
||||
*/
|
||||
export function readContextFile(workspacePath: string): string | null {
|
||||
const rulesFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc');
|
||||
if (!existsSync(rulesFile)) return null;
|
||||
return readFileSync(rulesFile, 'utf-8');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configure claude-mem MCP server in Cursor's mcp.json
|
||||
* Preserves existing MCP servers
|
||||
*/
|
||||
export function configureCursorMcp(mcpJsonPath: string, mcpServerScriptPath: string): void {
|
||||
const dir = join(mcpJsonPath, '..');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Load existing config or create new
|
||||
let config: CursorMcpConfig = { mcpServers: {} };
|
||||
if (existsSync(mcpJsonPath)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
config = { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Add claude-mem MCP server
|
||||
config.mcpServers['claude-mem'] = {
|
||||
command: 'node',
|
||||
args: [mcpServerScriptPath]
|
||||
};
|
||||
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claude-mem MCP server from Cursor's mcp.json
|
||||
* Preserves other MCP servers
|
||||
*/
|
||||
export function removeMcpConfig(mcpJsonPath: string): void {
|
||||
if (!existsSync(mcpJsonPath)) return;
|
||||
|
||||
try {
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
if (config.mcpServers && config.mcpServers['claude-mem']) {
|
||||
delete config.mcpServers['claude-mem'];
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON Utility Functions (mirrors common.sh logic)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse array field syntax like "workspace_roots[0]"
|
||||
* Returns null for simple fields
|
||||
*/
|
||||
export function parseArrayField(field: string): { field: string; index: number } | null {
|
||||
const match = field.match(/^(.+)\[(\d+)\]$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
field: match[1],
|
||||
index: parseInt(match[2], 10)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON field with fallback (mirrors common.sh json_get)
|
||||
* Supports array access like "field[0]"
|
||||
*/
|
||||
export function jsonGet(json: Record<string, unknown>, field: string, fallback: string = ''): string {
|
||||
const arrayAccess = parseArrayField(field);
|
||||
|
||||
if (arrayAccess) {
|
||||
const arr = json[arrayAccess.field];
|
||||
if (!Array.isArray(arr)) return fallback;
|
||||
const value = arr[arrayAccess.index];
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const value = json[field];
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project name from workspace path (mirrors common.sh get_project_name)
|
||||
*/
|
||||
export function getProjectName(workspacePath: string): string {
|
||||
if (!workspacePath) return 'unknown-project';
|
||||
|
||||
// Handle Windows drive root (C:\ or C:)
|
||||
const driveMatch = workspacePath.match(/^([A-Za-z]):[\\\/]?$/);
|
||||
if (driveMatch) {
|
||||
return `drive-${driveMatch[1].toUpperCase()}`;
|
||||
}
|
||||
|
||||
// Normalize to forward slashes for cross-platform support
|
||||
const normalized = workspacePath.replace(/\\/g, '/');
|
||||
const name = basename(normalized);
|
||||
|
||||
if (!name) {
|
||||
return 'unknown-project';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is empty/null (mirrors common.sh is_empty)
|
||||
* Also treats jq's literal "null" string as empty
|
||||
*/
|
||||
export function isEmpty(str: string | null | undefined): boolean {
|
||||
if (str === null || str === undefined) return true;
|
||||
if (str === '') return true;
|
||||
if (str === 'null') return true;
|
||||
if (str === 'empty') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string (mirrors common.sh url_encode)
|
||||
*/
|
||||
export function urlEncode(str: string): string {
|
||||
return encodeURIComponent(str);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { writeContextFile, readContextFile } from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Context Update functionality
|
||||
*
|
||||
* These tests validate that context files are correctly written to
|
||||
* .cursor/rules/claude-mem-context.mdc for registered projects.
|
||||
*
|
||||
* The context file uses Cursor's MDC format with frontmatter.
|
||||
*/
|
||||
|
||||
describe('Cursor Context Update', () => {
|
||||
let tempDir: string;
|
||||
let workspacePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-context-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
workspacePath = join(tempDir, 'my-project');
|
||||
mkdirSync(workspacePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('writeContextFile', () => {
|
||||
it('creates .cursor/rules directory structure', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesDir = join(workspacePath, '.cursor', 'rules');
|
||||
expect(existsSync(rulesDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates claude-mem-context.mdc file', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc');
|
||||
expect(existsSync(rulesFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes alwaysApply: true in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('includes description in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('description: "Claude-mem context from past sessions (auto-updated)"');
|
||||
});
|
||||
|
||||
it('includes the provided context in the file body', () => {
|
||||
const testContext = `## Recent Session
|
||||
|
||||
- Fixed authentication bug
|
||||
- Added new feature`;
|
||||
|
||||
writeContextFile(workspacePath, testContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Fixed authentication bug');
|
||||
expect(content).toContain('Added new feature');
|
||||
});
|
||||
|
||||
it('includes Memory Context header', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('# Memory Context from Past Sessions');
|
||||
});
|
||||
|
||||
it('includes footer with MCP tools mention', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain("Use claude-mem's MCP search tools for more detailed queries");
|
||||
});
|
||||
|
||||
it('uses atomic write (no temp file left behind)', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const tempFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc.tmp');
|
||||
expect(existsSync(tempFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites existing context file', () => {
|
||||
writeContextFile(workspacePath, 'first context');
|
||||
writeContextFile(workspacePath, 'second context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).not.toContain('first context');
|
||||
expect(content).toContain('second context');
|
||||
});
|
||||
|
||||
it('handles empty context gracefully', () => {
|
||||
writeContextFile(workspacePath, '');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toBeDefined();
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('preserves multi-line context with proper formatting', () => {
|
||||
const multilineContext = `Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
Paragraph 2`;
|
||||
|
||||
writeContextFile(workspacePath, multilineContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Line 1\nLine 2\nLine 3');
|
||||
expect(content).toContain('Paragraph 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MDC format validation', () => {
|
||||
it('has valid YAML frontmatter delimiters', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
|
||||
// First line should be ---
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Should have closing --- for frontmatter
|
||||
const secondDashIndex = lines.indexOf('---', 1);
|
||||
expect(secondDashIndex).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('frontmatter is parseable as YAML', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
const frontmatterEnd = lines.indexOf('---', 1);
|
||||
|
||||
const frontmatter = lines.slice(1, frontmatterEnd).join('\n');
|
||||
|
||||
// Should contain valid YAML key-value pairs
|
||||
expect(frontmatter).toMatch(/alwaysApply:\s*true/);
|
||||
expect(frontmatter).toMatch(/description:\s*"/);
|
||||
});
|
||||
|
||||
it('content after frontmatter is proper markdown', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
|
||||
// Should have markdown header
|
||||
expect(content).toMatch(/^# Memory Context/m);
|
||||
|
||||
// Should have horizontal rule (---)
|
||||
// Note: The footer uses --- which is also a horizontal rule in markdown
|
||||
const bodyPart = content.split('---')[2]; // After frontmatter
|
||||
expect(bodyPart).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles special characters in context', () => {
|
||||
const specialContext = '`code` **bold** _italic_ <html> $variable @mention #tag';
|
||||
|
||||
writeContextFile(workspacePath, specialContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('**bold**');
|
||||
expect(content).toContain('<html>');
|
||||
});
|
||||
|
||||
it('handles unicode in context', () => {
|
||||
const unicodeContext = 'Emoji: 🚀 Japanese: 日本語 Arabic: العربية';
|
||||
|
||||
writeContextFile(workspacePath, unicodeContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('🚀');
|
||||
expect(content).toContain('日本語');
|
||||
expect(content).toContain('العربية');
|
||||
});
|
||||
|
||||
it('handles very long context', () => {
|
||||
// 100KB of context
|
||||
const longContext = 'x'.repeat(100 * 1024);
|
||||
|
||||
writeContextFile(workspacePath, longContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain(longContext);
|
||||
});
|
||||
|
||||
it('works when .cursor directory already exists', () => {
|
||||
// Pre-create .cursor with other content
|
||||
mkdirSync(join(workspacePath, '.cursor', 'other'), { recursive: true });
|
||||
writeFileSync(join(workspacePath, '.cursor', 'other', 'file.txt'), 'existing');
|
||||
|
||||
writeContextFile(workspacePath, 'new context');
|
||||
|
||||
// Should not destroy existing content
|
||||
expect(existsSync(join(workspacePath, '.cursor', 'other', 'file.txt'))).toBe(true);
|
||||
expect(readContextFile(workspacePath)).toContain('new context');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, chmodSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Hook Script Outputs
|
||||
*
|
||||
* These tests validate that hook scripts produce the correct JSON output
|
||||
* required by Cursor's hook system.
|
||||
*
|
||||
* Critical requirements:
|
||||
* - beforeSubmitPrompt hooks MUST output {"continue": true}
|
||||
* - stop hooks MUST output valid JSON (usually {} or {"followup_message": "..."})
|
||||
*
|
||||
* If these outputs are wrong, Cursor will block prompts or fail silently.
|
||||
*/
|
||||
|
||||
// Skip these tests if jq is not installed (required by the scripts)
|
||||
function hasJq(): boolean {
|
||||
try {
|
||||
execSync('which jq', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip these tests on Windows (bash scripts)
|
||||
function isUnix(): boolean {
|
||||
return process.platform !== 'win32';
|
||||
}
|
||||
|
||||
const describeOrSkip = (hasJq() && isUnix()) ? describe : describe.skip;
|
||||
|
||||
describeOrSkip('Cursor Hook Script Outputs', () => {
|
||||
let tempDir: string;
|
||||
let cursorHooksDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-hook-output-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Find cursor-hooks directory
|
||||
cursorHooksDir = join(process.cwd(), 'cursor-hooks');
|
||||
if (!existsSync(cursorHooksDir)) {
|
||||
throw new Error('cursor-hooks directory not found');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Run a hook script with input and return the output
|
||||
*/
|
||||
function runHookScript(scriptName: string, input: object): string {
|
||||
const scriptPath = join(cursorHooksDir, scriptName);
|
||||
|
||||
if (!existsSync(scriptPath)) {
|
||||
throw new Error(`Script not found: ${scriptPath}`);
|
||||
}
|
||||
|
||||
// Make sure script is executable
|
||||
chmodSync(scriptPath, 0o755);
|
||||
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homedir(), // Ensure HOME is set for ~/.claude-mem access
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
describe('session-init.sh (beforeSubmitPrompt)', () => {
|
||||
it('outputs {"continue": true} for valid input', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-123',
|
||||
prompt: 'Hello world',
|
||||
workspace_roots: [tempDir]
|
||||
};
|
||||
|
||||
const output = runHookScript('session-init.sh', input);
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('outputs {"continue": true} even with empty input', () => {
|
||||
const output = runHookScript('session-init.sh', {});
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('outputs {"continue": true} even with invalid JSON-like input', () => {
|
||||
const input = {
|
||||
conversation_id: null,
|
||||
workspace_roots: null
|
||||
};
|
||||
|
||||
const output = runHookScript('session-init.sh', input);
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('output is valid JSON', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-123',
|
||||
prompt: 'Test prompt'
|
||||
};
|
||||
|
||||
const output = runHookScript('session-init.sh', input);
|
||||
|
||||
// Should not throw
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('context-inject.sh (beforeSubmitPrompt)', () => {
|
||||
it('outputs {"continue": true} for valid input', () => {
|
||||
const input = {
|
||||
workspace_roots: [tempDir]
|
||||
};
|
||||
|
||||
const output = runHookScript('context-inject.sh', input);
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('outputs {"continue": true} even with empty input', () => {
|
||||
const output = runHookScript('context-inject.sh', {});
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('output is valid JSON', () => {
|
||||
const output = runHookScript('context-inject.sh', {});
|
||||
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('session-summary.sh (stop)', () => {
|
||||
it('outputs valid JSON for typical input', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-456',
|
||||
workspace_roots: [tempDir],
|
||||
status: 'completed'
|
||||
};
|
||||
|
||||
const output = runHookScript('session-summary.sh', input);
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
});
|
||||
|
||||
it('outputs empty object {} when nothing to report', () => {
|
||||
const input = {
|
||||
// No conversation_id - should exit early with {}
|
||||
};
|
||||
|
||||
const output = runHookScript('session-summary.sh', input);
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
|
||||
it('output is valid JSON even with minimal input', () => {
|
||||
const output = runHookScript('session-summary.sh', {});
|
||||
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save-observation.sh (afterMCPExecution)', () => {
|
||||
it('exits cleanly with no output for valid MCP input', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-789',
|
||||
hook_event_name: 'afterMCPExecution',
|
||||
tool_name: 'Bash',
|
||||
tool_input: { command: 'ls' },
|
||||
result_json: { output: 'file1.txt' },
|
||||
workspace_roots: [tempDir]
|
||||
};
|
||||
|
||||
// This script should exit with 0 and produce no output
|
||||
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should be empty or just whitespace
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('exits cleanly for shell execution input', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-101',
|
||||
hook_event_name: 'afterShellExecution',
|
||||
command: 'ls -la',
|
||||
output: 'file1.txt\nfile2.txt',
|
||||
workspace_roots: [tempDir]
|
||||
};
|
||||
|
||||
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should be empty or just whitespace
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('exits cleanly with no session_id', () => {
|
||||
const input = {
|
||||
hook_event_name: 'afterMCPExecution',
|
||||
tool_name: 'Bash'
|
||||
// No conversation_id or generation_id
|
||||
};
|
||||
|
||||
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should exit cleanly
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('save-file-edit.sh (afterFileEdit)', () => {
|
||||
it('exits cleanly with valid file edit input', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-edit',
|
||||
file_path: '/path/to/file.ts',
|
||||
edits: [
|
||||
{ old_string: 'old code', new_string: 'new code' }
|
||||
],
|
||||
workspace_roots: [tempDir]
|
||||
};
|
||||
|
||||
const scriptPath = join(cursorHooksDir, 'save-file-edit.sh');
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should be empty or just whitespace
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('exits cleanly with no file_path', () => {
|
||||
const input = {
|
||||
conversation_id: 'test-conv-edit',
|
||||
edits: []
|
||||
// No file_path - should exit early
|
||||
};
|
||||
|
||||
const scriptPath = join(cursorHooksDir, 'save-file-edit.sh');
|
||||
const result = execSync(`bash "${scriptPath}"`, {
|
||||
input: JSON.stringify(input),
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should exit cleanly
|
||||
expect(result.trim()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('script error handling', () => {
|
||||
it('session-init.sh never outputs error to stdout', () => {
|
||||
// Even with completely broken input, should still output valid JSON
|
||||
const scriptPath = join(cursorHooksDir, 'session-init.sh');
|
||||
|
||||
// Pass invalid input that might cause jq errors
|
||||
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Output should still be valid JSON with continue: true
|
||||
const parsed = JSON.parse(result.trim());
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('context-inject.sh never outputs error to stdout', () => {
|
||||
const scriptPath = join(cursorHooksDir, 'context-inject.sh');
|
||||
|
||||
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result.trim());
|
||||
expect(parsed.continue).toBe(true);
|
||||
});
|
||||
|
||||
it('session-summary.sh never outputs error to stdout', () => {
|
||||
const scriptPath = join(cursorHooksDir, 'session-summary.sh');
|
||||
|
||||
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(result.trim())).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
parseArrayField,
|
||||
jsonGet,
|
||||
getProjectName,
|
||||
isEmpty,
|
||||
urlEncode
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Hooks JSON/Utility Functions
|
||||
*
|
||||
* These tests validate the logic used in common.sh bash utilities.
|
||||
* The TypeScript implementations in cursor-utils.ts mirror the bash logic,
|
||||
* allowing us to verify correct behavior and catch edge cases.
|
||||
*
|
||||
* The bash scripts use these functions:
|
||||
* - json_get: Extract fields from JSON, including array access
|
||||
* - get_project_name: Extract project name from workspace path
|
||||
* - is_empty: Check if a string is empty/null
|
||||
* - url_encode: URL-encode a string
|
||||
*/
|
||||
|
||||
describe('Cursor Hooks JSON Utilities', () => {
|
||||
describe('parseArrayField', () => {
|
||||
it('parses simple array access', () => {
|
||||
const result = parseArrayField('workspace_roots[0]');
|
||||
expect(result).toEqual({ field: 'workspace_roots', index: 0 });
|
||||
});
|
||||
|
||||
it('parses array access with higher index', () => {
|
||||
const result = parseArrayField('items[42]');
|
||||
expect(result).toEqual({ field: 'items', index: 42 });
|
||||
});
|
||||
|
||||
it('returns null for simple field', () => {
|
||||
const result = parseArrayField('conversation_id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
const result = parseArrayField('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed array syntax', () => {
|
||||
expect(parseArrayField('field[]')).toBeNull();
|
||||
expect(parseArrayField('field[-1]')).toBeNull();
|
||||
expect(parseArrayField('[0]')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles underscores in field name', () => {
|
||||
const result = parseArrayField('my_array_field[5]');
|
||||
expect(result).toEqual({ field: 'my_array_field', index: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonGet', () => {
|
||||
const testJson = {
|
||||
conversation_id: 'conv-123',
|
||||
workspace_roots: ['/path/to/project', '/another/path'],
|
||||
nested: { value: 'nested-value' },
|
||||
empty_string: '',
|
||||
null_value: null
|
||||
};
|
||||
|
||||
it('gets simple field', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id')).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('gets array element with [0]', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[0]')).toBe('/path/to/project');
|
||||
});
|
||||
|
||||
it('gets array element with higher index', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[1]')).toBe('/another/path');
|
||||
});
|
||||
|
||||
it('returns fallback for missing field', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for out-of-bounds array access', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[99]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for array access on non-array', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id[0]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns empty string fallback by default', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent')).toBe('');
|
||||
});
|
||||
|
||||
it('returns fallback for null value', () => {
|
||||
expect(jsonGet(testJson, 'null_value', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns empty string value (not fallback)', () => {
|
||||
// Empty string is a valid value, should not trigger fallback
|
||||
expect(jsonGet(testJson, 'empty_string', 'fallback')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectName', () => {
|
||||
it('extracts basename from Unix path', () => {
|
||||
expect(getProjectName('/Users/alex/projects/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('extracts basename from Windows path', () => {
|
||||
expect(getProjectName('C:\\Users\\alex\\projects\\my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('handles path with trailing slash', () => {
|
||||
expect(getProjectName('/path/to/project/')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns unknown-project for empty string', () => {
|
||||
expect(getProjectName('')).toBe('unknown-project');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:\\', () => {
|
||||
expect(getProjectName('C:\\')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:', () => {
|
||||
expect(getProjectName('C:')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles lowercase drive letter', () => {
|
||||
expect(getProjectName('d:\\')).toBe('drive-D');
|
||||
});
|
||||
|
||||
it('handles project name with dots', () => {
|
||||
expect(getProjectName('/path/to/my.project.v2')).toBe('my.project.v2');
|
||||
});
|
||||
|
||||
it('handles project name with spaces', () => {
|
||||
expect(getProjectName('/path/to/My Project')).toBe('My Project');
|
||||
});
|
||||
|
||||
it('handles project name with special characters', () => {
|
||||
expect(getProjectName('/path/to/project-name_v2.0')).toBe('project-name_v2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('returns true for null', () => {
|
||||
expect(isEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for undefined', () => {
|
||||
expect(isEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for empty string', () => {
|
||||
expect(isEmpty('')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "null" string', () => {
|
||||
// This is important - jq returns "null" as string when value is null
|
||||
expect(isEmpty('null')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "empty" string', () => {
|
||||
expect(isEmpty('empty')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-empty string', () => {
|
||||
expect(isEmpty('some-value')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
// Whitespace is not empty
|
||||
expect(isEmpty(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "0" string', () => {
|
||||
expect(isEmpty('0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "false" string', () => {
|
||||
expect(isEmpty('false')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urlEncode', () => {
|
||||
it('encodes spaces', () => {
|
||||
expect(urlEncode('hello world')).toBe('hello%20world');
|
||||
});
|
||||
|
||||
it('encodes special characters', () => {
|
||||
expect(urlEncode('a&b=c')).toBe('a%26b%3Dc');
|
||||
});
|
||||
|
||||
it('encodes unicode', () => {
|
||||
const encoded = urlEncode('日本語');
|
||||
expect(encoded).toContain('%');
|
||||
expect(decodeURIComponent(encoded)).toBe('日本語');
|
||||
});
|
||||
|
||||
it('preserves alphanumeric characters', () => {
|
||||
expect(urlEncode('abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('preserves dashes and underscores', () => {
|
||||
expect(urlEncode('my-project_name')).toBe('my-project_name');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(urlEncode('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodes forward slash', () => {
|
||||
expect(urlEncode('path/to/file')).toBe('path%2Fto%2Ffile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: hook payload parsing', () => {
|
||||
// Simulates parsing a real Cursor hook payload
|
||||
|
||||
it('extracts all fields from typical beforeSubmitPrompt payload', () => {
|
||||
const payload = {
|
||||
conversation_id: 'abc-123',
|
||||
generation_id: 'gen-456',
|
||||
prompt: 'Fix the bug',
|
||||
workspace_roots: ['/Users/alex/projects/my-project'],
|
||||
hook_event_name: 'beforeSubmitPrompt'
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]');
|
||||
const projectName = getProjectName(workspaceRoot);
|
||||
const hookEvent = jsonGet(payload, 'hook_event_name');
|
||||
|
||||
expect(conversationId).toBe('abc-123');
|
||||
expect(workspaceRoot).toBe('/Users/alex/projects/my-project');
|
||||
expect(projectName).toBe('my-project');
|
||||
expect(hookEvent).toBe('beforeSubmitPrompt');
|
||||
});
|
||||
|
||||
it('handles payload with missing optional fields', () => {
|
||||
const payload = {
|
||||
generation_id: 'gen-456',
|
||||
// No conversation_id, no workspace_roots
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id', '');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]', '');
|
||||
|
||||
expect(isEmpty(conversationId)).toBe(true);
|
||||
expect(isEmpty(workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it('constructs valid API URL with encoded project name', () => {
|
||||
const projectName = 'my project (v2)';
|
||||
const port = 37777;
|
||||
const encoded = urlEncode(projectName);
|
||||
|
||||
const url = `http://127.0.0.1:${port}/api/context/inject?project=${encoded}`;
|
||||
|
||||
expect(url).toBe('http://127.0.0.1:37777/api/context/inject?project=my%20project%20(v2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
configureCursorMcp,
|
||||
removeMcpConfig,
|
||||
type CursorMcpConfig
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor MCP Configuration
|
||||
*
|
||||
* These tests validate the MCP server configuration that gets written
|
||||
* to .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (user-level).
|
||||
*
|
||||
* The config must match Cursor's expected format for MCP servers.
|
||||
*/
|
||||
|
||||
describe('Cursor MCP Configuration', () => {
|
||||
let tempDir: string;
|
||||
let mcpJsonPath: string;
|
||||
const mcpServerPath = '/path/to/mcp-server.cjs';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
mcpJsonPath = join(tempDir, '.cursor', 'mcp.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('configureCursorMcp', () => {
|
||||
it('creates mcp.json if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(mcpJsonPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates .cursor directory if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(join(tempDir, '.cursor'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds claude-mem server with correct structure', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem'].command).toBe('node');
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([mcpServerPath]);
|
||||
});
|
||||
|
||||
it('preserves existing MCP servers when adding claude-mem', () => {
|
||||
// Pre-create config with another server
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const existingConfig = {
|
||||
mcpServers: {
|
||||
'other-server': {
|
||||
command: 'python',
|
||||
args: ['/path/to/other.py']
|
||||
}
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(existingConfig));
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Both servers should exist
|
||||
expect(config.mcpServers['other-server']).toBeDefined();
|
||||
expect(config.mcpServers['other-server'].command).toBe('python');
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing claude-mem server path', () => {
|
||||
// First config
|
||||
configureCursorMcp(mcpJsonPath, '/old/path.cjs');
|
||||
|
||||
// Update with new path
|
||||
const newPath = '/new/path.cjs';
|
||||
configureCursorMcp(mcpJsonPath, newPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([newPath]);
|
||||
});
|
||||
|
||||
it('recovers from corrupt mcp.json', () => {
|
||||
// Create corrupt file
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, 'not valid json {{{{');
|
||||
|
||||
// Should not throw, should overwrite
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles mcp.json with missing mcpServers key', () => {
|
||||
// Create file with empty object
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, '{}');
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP config format validation', () => {
|
||||
it('produces valid JSON', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should not throw
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('uses pretty-printed JSON (2-space indent)', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should contain newlines and indentation
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' "mcpServers"');
|
||||
});
|
||||
|
||||
it('matches Cursor MCP server schema', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Top-level must have mcpServers
|
||||
expect(config).toHaveProperty('mcpServers');
|
||||
expect(typeof config.mcpServers).toBe('object');
|
||||
|
||||
// Each server must have command (string) and optionally args (array)
|
||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||
expect(typeof name).toBe('string');
|
||||
expect((server as { command: string }).command).toBeDefined();
|
||||
expect(typeof (server as { command: string }).command).toBe('string');
|
||||
|
||||
const args = (server as { args?: string[] }).args;
|
||||
if (args !== undefined) {
|
||||
expect(Array.isArray(args)).toBe(true);
|
||||
args.forEach((arg: string) => expect(typeof arg).toBe('string'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMcpConfig', () => {
|
||||
it('removes claude-mem server from config', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other servers when removing claude-mem', () => {
|
||||
// Setup: both servers
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] },
|
||||
'claude-mem': { command: 'node', args: ['/mcp.cjs'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
expect(updated.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing if mcp.json does not exist', () => {
|
||||
// Should not throw
|
||||
expect(() => removeMcpConfig(mcpJsonPath)).not.toThrow();
|
||||
expect(existsSync(mcpJsonPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if claude-mem not in config', () => {
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path handling', () => {
|
||||
it('handles absolute path with spaces', () => {
|
||||
const pathWithSpaces = '/path/to/my project/mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, pathWithSpaces);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([pathWithSpaces]);
|
||||
});
|
||||
|
||||
it('handles Windows-style path', () => {
|
||||
const windowsPath = 'C:\\Users\\alex\\.claude\\plugins\\mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, windowsPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([windowsPath]);
|
||||
});
|
||||
|
||||
it('handles path with special characters', () => {
|
||||
const specialPath = "/path/to/project-name_v2.0 (beta)/mcp-server.cjs";
|
||||
configureCursorMcp(mcpJsonPath, specialPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([specialPath]);
|
||||
|
||||
// Verify it survives JSON round-trip
|
||||
const reread: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(reread.mcpServers['claude-mem'].args![0]).toBe(specialPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
readCursorRegistry,
|
||||
writeCursorRegistry,
|
||||
registerCursorProject,
|
||||
unregisterCursorProject
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Project Registry functionality
|
||||
*
|
||||
* These tests validate the file-based registry that tracks which projects
|
||||
* have Cursor hooks installed for automatic context updates.
|
||||
*
|
||||
* The registry is stored at ~/.claude-mem/cursor-projects.json
|
||||
*/
|
||||
|
||||
describe('Cursor Project Registry', () => {
|
||||
let tempDir: string;
|
||||
let registryFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-registry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
registryFile = join(tempDir, 'cursor-projects.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('readCursorRegistry', () => {
|
||||
it('returns empty object when registry file does not exist', () => {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when registry file is corrupt JSON', () => {
|
||||
writeFileSync(registryFile, 'not valid json {{{');
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed registry when file exists', () => {
|
||||
const expected = {
|
||||
'my-project': {
|
||||
workspacePath: '/home/user/projects/my-project',
|
||||
installedAt: '2025-01-01T00:00:00.000Z'
|
||||
}
|
||||
};
|
||||
writeFileSync(registryFile, JSON.stringify(expected));
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCursorProject', () => {
|
||||
it('creates registry file if it does not exist', () => {
|
||||
registerCursorProject(registryFile, 'new-project', '/path/to/project');
|
||||
|
||||
expect(existsSync(registryFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores project with workspacePath and installedAt', () => {
|
||||
const before = Date.now();
|
||||
registerCursorProject(registryFile, 'test-project', '/workspace/test');
|
||||
const after = Date.now();
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['test-project']).toBeDefined();
|
||||
expect(registry['test-project'].workspacePath).toBe('/workspace/test');
|
||||
|
||||
// Verify installedAt is a valid ISO timestamp within the test window
|
||||
const installedAt = new Date(registry['test-project'].installedAt).getTime();
|
||||
expect(installedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(installedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('preserves existing projects when registering new one', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(2);
|
||||
expect(registry['project-a'].workspacePath).toBe('/path/a');
|
||||
expect(registry['project-b'].workspacePath).toBe('/path/b');
|
||||
});
|
||||
|
||||
it('overwrites existing project with same name', () => {
|
||||
registerCursorProject(registryFile, 'my-project', '/old/path');
|
||||
registerCursorProject(registryFile, 'my-project', '/new/path');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(1);
|
||||
expect(registry['my-project'].workspacePath).toBe('/new/path');
|
||||
});
|
||||
|
||||
it('handles special characters in project name', () => {
|
||||
const projectName = 'my-project_v2.0 (beta)';
|
||||
registerCursorProject(registryFile, projectName, '/path/to/project');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry[projectName]).toBeDefined();
|
||||
expect(registry[projectName].workspacePath).toBe('/path/to/project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterCursorProject', () => {
|
||||
it('removes specified project from registry', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
unregisterCursorProject(registryFile, 'project-a');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['project-a']).toBeUndefined();
|
||||
expect(registry['project-b']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does nothing when unregistering non-existent project', () => {
|
||||
registerCursorProject(registryFile, 'existing', '/path');
|
||||
|
||||
// Should not throw
|
||||
unregisterCursorProject(registryFile, 'non-existent');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['existing']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles unregister when registry file does not exist', () => {
|
||||
// Should not throw even when file doesn't exist
|
||||
unregisterCursorProject(registryFile, 'any-project');
|
||||
|
||||
// File should not be created by unregister
|
||||
expect(existsSync(registryFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry format validation', () => {
|
||||
it('stores registry as pretty-printed JSON', () => {
|
||||
registerCursorProject(registryFile, 'test', '/path');
|
||||
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
// Should be indented (pretty-printed)
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' ');
|
||||
});
|
||||
|
||||
it('registry file is valid JSON that can be read by other tools', () => {
|
||||
registerCursorProject(registryFile, 'project-1', '/path/1');
|
||||
registerCursorProject(registryFile, 'project-2', '/path/2');
|
||||
|
||||
// Read raw and parse with JSON.parse (not our helper)
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('project-1');
|
||||
expect(parsed).toHaveProperty('project-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user