feat(cursor): Add Windows PowerShell support for Cursor hooks

Complete Windows parity with bash scripts:
- Create 7 PowerShell scripts mirroring bash functionality
- Update installer to detect platform and install appropriate scripts
- Generate platform-specific hooks.json with PowerShell invocation
- Add enterprise support for Windows (ProgramData/Cursor)
- Update findCursorHooksDir to check for both .sh and .ps1
- Add comprehensive Windows documentation to STANDALONE-SETUP.md

Scripts added: common.ps1, session-init.ps1, context-inject.ps1,
save-observation.ps1, save-file-edit.ps1, session-summary.ps1, user-message.ps1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-12-29 21:16:41 -05:00
parent ed8269250d
commit a82d1a24b9
10 changed files with 1086 additions and 117 deletions
+96
View File
@@ -16,6 +16,7 @@ Use claude-mem's persistent memory in Cursor without a Claude Code subscription.
## Prerequisites
### macOS / Linux
- Cursor IDE
- Node.js 18+
- Git
@@ -23,6 +24,12 @@ Use claude-mem's persistent memory in Cursor without a Claude Code subscription.
- **macOS**: `brew install jq curl`
- **Linux**: `apt install jq curl`
### Windows
- Cursor IDE
- Node.js 18+
- Git
- PowerShell 5.1+ (included with Windows 10/11)
## Step 1: Clone Claude-Mem
```bash
@@ -195,3 +202,92 @@ If you hit the 1500 requests/day limit:
| `npm run worker:start` | Start the background worker |
| `npm run worker:stop` | Stop the background worker |
| `npm 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
npm install
npm 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)
npm run cursor:setup
# Or manual installation
npm run cursor:install
npm 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.
+193
View File
@@ -0,0 +1,193 @@
# Common utility functions for Cursor hooks (PowerShell)
# Dot-source this file in hook scripts: . "$PSScriptRoot\common.ps1"
$ErrorActionPreference = "SilentlyContinue"
# 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
}
}
+74
View File
@@ -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
+126
View File
@@ -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
+126
View File
@@ -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
+79
View File
@@ -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
+108
View File
@@ -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
+103
View File
@@ -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
File diff suppressed because one or more lines are too long
+105 -41
View File
@@ -1286,6 +1286,7 @@ async function detectClaudeCode(): Promise<boolean> {
/**
* 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 = [
@@ -1296,9 +1297,10 @@ function findCursorHooksDir(): string | null {
// Alternative dev location
path.join(process.cwd(), 'cursor-hooks'),
];
for (const p of possiblePaths) {
if (existsSync(path.join(p, 'common.sh'))) {
// Check for either bash or PowerShell common script
if (existsSync(path.join(p, 'common.sh')) || existsSync(path.join(p, 'common.ps1'))) {
return p;
}
}
@@ -1368,16 +1370,33 @@ For more info: https://docs.claude-mem.ai/cursor
}
}
/**
* 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> {
console.log(`\n📦 Installing Claude-Mem Cursor hooks (${target} level)...\n`);
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');
@@ -1394,8 +1413,11 @@ async function installCursorHooks(sourceDir: string, target: string): Promise<nu
} 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 yet supported on Windows');
console.error('❌ Enterprise installation not supported on this platform');
return 1;
}
break;
@@ -1403,56 +1425,76 @@ async function installCursorHooks(sourceDir: string, target: string): Promise<nu
console.error(`❌ Invalid target: ${target}. Use: project, user, or enterprise`);
return 1;
}
try {
// Create directories
mkdirSync(hooksDir, { recursive: true });
// Copy hook scripts
const scripts = ['common.sh', 'session-init.sh', 'context-inject.sh',
'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh'];
// 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');
writeFileSync(dstPath, content, { mode: 0o755 });
// 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
// 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: `${hookPrefix}session-init.sh` },
{ command: `${hookPrefix}context-inject.sh` }
{ command: makeHookCommand('session-init') },
{ command: makeHookCommand('context-inject') }
],
afterMCPExecution: [
{ command: `${hookPrefix}save-observation.sh` }
{ command: makeHookCommand('save-observation') }
],
afterShellExecution: [
{ command: `${hookPrefix}save-observation.sh` }
{ command: makeHookCommand('save-observation') }
],
afterFileEdit: [
{ command: `${hookPrefix}save-file-edit.sh` }
{ command: makeHookCommand('save-file-edit') }
],
stop: [
{ command: `${hookPrefix}session-summary.sh` }
{ command: makeHookCommand('session-summary') }
]
}
};
writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
console.log(` ✓ Created hooks.json`);
console.log(` ✓ Created hooks.json (${platform} mode)`);
// For project-level: create initial context file
if (target === 'project') {
@@ -1584,12 +1626,16 @@ function uninstallCursorHooks(target: string): number {
try {
const hooksDir = path.join(targetDir, 'hooks');
const hooksJsonPath = path.join(targetDir, 'hooks.json');
// Remove hook scripts
const scripts = ['common.sh', 'session-init.sh', 'context-inject.sh',
'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh'];
for (const script of scripts) {
// 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);
@@ -1649,22 +1695,40 @@ function checkCursorHooksStatus(): number {
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}`);
// Check for hook scripts
const scripts = ['session-init.sh', 'context-inject.sh', 'save-observation.sh'];
const missing = scripts.filter(s => !existsSync(path.join(hooksDir, s)));
if (missing.length > 0) {
console.log(` ⚠ Missing scripts: ${missing.join(', ')}`);
// 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(` Scripts: All present`);
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');