Merge pull request #1592 from thedotmack/thedotmack/npx-gemini-cli
feat: npx CLI, Gemini CLI, and multi-IDE integrations
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
datasets/
|
datasets/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
!installer/dist/
|
|
||||||
**/_tree-sitter/
|
**/_tree-sitter/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Source code (dist/ and plugin/ are the shipped artifacts)
|
||||||
|
src/
|
||||||
|
scripts/
|
||||||
|
tests/
|
||||||
|
docs/
|
||||||
|
datasets/
|
||||||
|
private/
|
||||||
|
antipattern-czar/
|
||||||
|
|
||||||
|
# Heavy binaries installed at runtime via smart-install.js
|
||||||
|
plugin/node_modules/
|
||||||
|
plugin/scripts/claude-mem
|
||||||
|
plugin/bun.lock
|
||||||
|
plugin/data/
|
||||||
|
plugin/data.backup/
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.ts
|
||||||
|
!*.d.ts
|
||||||
|
tsconfig*.json
|
||||||
|
.eslintrc*
|
||||||
|
.prettierrc*
|
||||||
|
.editorconfig
|
||||||
|
jest.config*
|
||||||
|
vitest.config*
|
||||||
|
|
||||||
|
# Git and CI
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.mcp.json
|
||||||
|
.plan/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
Auto Run Docs/
|
||||||
|
~*/
|
||||||
|
http*/
|
||||||
|
https*/
|
||||||
|
.idea/
|
||||||
@@ -127,17 +127,29 @@
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Start a new Claude Code session in the terminal and enter the following commands:
|
Install with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or install for Gemini CLI (auto-detects `~/.gemini`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install --ide gemini-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
|
|
||||||
/plugin install claude-mem
|
/plugin install claude-mem
|
||||||
```
|
```
|
||||||
|
|
||||||
Restart Claude Code. Context from previous sessions will automatically appear in new sessions.
|
Restart Claude Code or Gemini CLI. Context from previous sessions will automatically appear in new sessions.
|
||||||
|
|
||||||
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. To use Claude-Mem as a plugin, always install via the `/plugin` commands above.
|
> **Note:** Claude-Mem is also published on npm, but `npm install -g claude-mem` installs the **SDK/library only** — it does not register the plugin hooks or set up the worker service. Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||||
|
|
||||||
### 🦞 OpenClaw Gateway
|
### 🦞 OpenClaw Gateway
|
||||||
|
|
||||||
@@ -171,6 +183,7 @@ The installer handles dependencies, plugin setup, AI provider configuration, wor
|
|||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
|
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
|
||||||
|
- **[Gemini CLI Setup](https://docs.claude-mem.ai/gemini-cli/setup)** - Dedicated guide for Google's Gemini CLI integration
|
||||||
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
|
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
|
||||||
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
|
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
|
||||||
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
|
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
|
||||||
|
|||||||
@@ -57,6 +57,13 @@
|
|||||||
"cursor/openrouter-setup"
|
"cursor/openrouter-setup"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"group": "Gemini CLI Integration",
|
||||||
|
"icon": "terminal",
|
||||||
|
"pages": [
|
||||||
|
"gemini-cli/setup"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"group": "Best Practices",
|
"group": "Best Practices",
|
||||||
"icon": "lightbulb",
|
"icon": "lightbulb",
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
title: "Gemini CLI Setup"
|
||||||
|
description: "Add persistent memory to Gemini CLI with claude-mem"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gemini CLI Setup
|
||||||
|
|
||||||
|
> **Give Gemini CLI persistent memory across sessions.**
|
||||||
|
|
||||||
|
Gemini CLI starts every session from scratch. Claude-mem changes that by capturing observations, decisions, and patterns — then injecting relevant context into each new session.
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
**How it works:** Claude-mem installs lifecycle hooks into Gemini CLI that capture tool usage, agent responses, and session events. A local worker service extracts semantic observations and injects relevant history at session start.
|
||||||
|
</Info>
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and configured
|
||||||
|
- [Node.js](https://nodejs.org/) 18+
|
||||||
|
- The `~/.gemini` directory must exist (created by Gemini CLI on first run)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Step 1: Install claude-mem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer will:
|
||||||
|
1. Auto-detect Gemini CLI (checks for `~/.gemini` directory)
|
||||||
|
2. Prompt you to select **Gemini CLI** from the IDE picker
|
||||||
|
3. Install 8 lifecycle hooks into `~/.gemini/settings.json`
|
||||||
|
4. Inject context configuration into `~/.gemini/GEMINI.md`
|
||||||
|
5. Start the worker service
|
||||||
|
|
||||||
|
### Step 2: Configure an AI provider
|
||||||
|
|
||||||
|
Claude-mem needs an AI provider to extract observations from your sessions. Choose one:
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="Gemini API (Free)">
|
||||||
|
The simplest option — use Gemini's own API for observation extraction:
|
||||||
|
|
||||||
|
1. Get a free API key from [Google AI Studio](https://aistudio.google.com/apikey)
|
||||||
|
2. Add it to your settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "gemini",
|
||||||
|
"CLAUDE_MEM_GEMINI_API_KEY": "YOUR_API_KEY"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
**Free tier:** 1,500 requests/day with `gemini-2.5-flash-lite`. Enable billing on Google Cloud for 4,000 RPM without charges.
|
||||||
|
</Tip>
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Claude SDK">
|
||||||
|
If you have a Claude API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "claude"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Set your API key via environment variable:
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY="your-key"
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
<Tab title="OpenRouter">
|
||||||
|
For access to 100+ models:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude-mem
|
||||||
|
cat > ~/.claude-mem/settings.json << 'EOF'
|
||||||
|
{
|
||||||
|
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||||
|
"CLAUDE_MEM_OPENROUTER_API_KEY": "YOUR_KEY"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
### Step 3: Verify installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check worker is running
|
||||||
|
npx claude-mem status
|
||||||
|
|
||||||
|
# Check hooks are installed — look for claude-mem entries
|
||||||
|
cat ~/.gemini/settings.json | grep claude-mem
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:37777 to see the memory viewer.
|
||||||
|
|
||||||
|
### Step 4: Start using Gemini CLI
|
||||||
|
|
||||||
|
Launch Gemini CLI normally. Claude-mem works in the background:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini
|
||||||
|
```
|
||||||
|
|
||||||
|
On session start, you'll see claude-mem context injected with your recent observations and project history.
|
||||||
|
|
||||||
|
## What gets captured
|
||||||
|
|
||||||
|
Claude-mem registers 8 of Gemini CLI's 11 lifecycle hooks:
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| **SessionStart** | Injects memory context into the session |
|
||||||
|
| **SessionEnd** | Marks session complete, triggers summary |
|
||||||
|
| **PreCompress** | Captures session summary before compression |
|
||||||
|
| **Notification** | Records system events (permissions, etc.) |
|
||||||
|
| **BeforeAgent** | Captures user prompts |
|
||||||
|
| **AfterAgent** | Records full agent responses |
|
||||||
|
| **BeforeTool** | Logs tool invocations before execution |
|
||||||
|
| **AfterTool** | Captures tool results after execution |
|
||||||
|
|
||||||
|
Three model-level hooks (BeforeModel, AfterModel, BeforeToolSelection) are intentionally skipped — they fire per-LLM-call and are too noisy for memory capture.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hooks not firing
|
||||||
|
|
||||||
|
1. Verify hooks exist in settings:
|
||||||
|
```bash
|
||||||
|
cat ~/.gemini/settings.json
|
||||||
|
```
|
||||||
|
You should see entries like `"SessionStart"`, `"AfterTool"`, etc. with claude-mem commands.
|
||||||
|
|
||||||
|
2. Restart Gemini CLI after installation.
|
||||||
|
|
||||||
|
3. Re-run the installer:
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Worker not running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status
|
||||||
|
npx claude-mem status
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
npx claude-mem logs
|
||||||
|
|
||||||
|
# Restart worker
|
||||||
|
npx claude-mem restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### No context appearing at session start
|
||||||
|
|
||||||
|
1. Ensure the worker is running (check http://localhost:37777)
|
||||||
|
2. You need at least one previous session with observations for context to appear
|
||||||
|
3. Check your AI provider is configured in `~/.claude-mem/settings.json`
|
||||||
|
|
||||||
|
### Raw escape codes in output
|
||||||
|
|
||||||
|
If you see characters like `[31m` or `[0m` in the session context, your claude-mem version may need updating:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
This was fixed in v10.6.3+ — the Gemini CLI adapter now strips ANSI color codes automatically.
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes hooks from `~/.gemini/settings.json` and cleans up `~/.gemini/GEMINI.md`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- [Gemini Provider](/usage/gemini-provider) — Configure the Gemini AI provider for observation extraction
|
||||||
|
- [Configuration](/configuration) — All settings options
|
||||||
|
- [Search Tools](/usage/search-tools) — Search your memory from within sessions
|
||||||
|
- [Troubleshooting](/troubleshooting) — Common issues and solutions
|
||||||
@@ -7,24 +7,35 @@ description: "Install Claude-Mem plugin for persistent memory across sessions"
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Install Claude-Mem directly from the plugin marketplace:
|
### Option 1: npx (Recommended)
|
||||||
|
|
||||||
|
Install and configure Claude-Mem with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
The interactive installer will:
|
||||||
|
- Detect your installed IDEs (Claude Code, Cursor, Gemini CLI, Windsurf, etc.)
|
||||||
|
- Copy plugin files to the correct locations
|
||||||
|
- Register the plugin with Claude Code
|
||||||
|
- Install all dependencies (including Bun and uv)
|
||||||
|
- Auto-start the worker service
|
||||||
|
|
||||||
|
### Option 2: Plugin Marketplace
|
||||||
|
|
||||||
|
Install Claude-Mem directly from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
/plugin install claude-mem
|
/plugin install claude-mem
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! The plugin will automatically:
|
Both methods will automatically configure hooks and start the worker service. Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
||||||
- Download prebuilt binaries (no compilation needed)
|
|
||||||
- Install all dependencies (including SQLite binaries)
|
|
||||||
- Configure hooks for session lifecycle management
|
|
||||||
- Auto-start the worker service on first session
|
|
||||||
|
|
||||||
Start a new Claude Code session and you'll see context from previous sessions automatically loaded.
|
|
||||||
|
|
||||||
> **Important:** Claude-Mem is published on npm, but running `npm install -g claude-mem` installs the
|
> **Important:** Claude-Mem is published on npm, but running `npm install -g claude-mem` installs the
|
||||||
> **SDK/library only**. It does **not** register plugin hooks or start the worker service.
|
> **SDK/library only**. It does **not** register plugin hooks or start the worker service.
|
||||||
> To use Claude-Mem as a persistent memory plugin, always install via the `/plugin` commands above.
|
> Always install via `npx claude-mem install` or the `/plugin` commands above.
|
||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ Claude-Mem seamlessly preserves context across sessions by automatically capturi
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Start a new Claude Code session in the terminal and enter the following commands:
|
Install with a single command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx claude-mem install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install from the plugin marketplace inside Claude Code:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/plugin marketplace add thedotmack/claude-mem
|
/plugin marketplace add thedotmack/claude-mem
|
||||||
|
|||||||
+15
-49
@@ -1,59 +1,25 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# claude-mem installer bootstrap
|
# claude-mem installer redirect
|
||||||
# Usage: curl -fsSL https://install.cmem.ai | bash
|
# The old curl-pipe-bash installer has been replaced by npx claude-mem.
|
||||||
# or: curl -fsSL https://install.cmem.ai | bash -s -- --provider=gemini --api-key=YOUR_KEY
|
# This script now redirects users to the new install method.
|
||||||
|
|
||||||
INSTALLER_URL="https://install.cmem.ai/installer.js"
|
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
error() { echo -e "${RED}Error: $1${NC}" >&2; exit 1; }
|
echo ""
|
||||||
info() { echo -e "${CYAN}$1${NC}"; }
|
echo -e "${YELLOW}The curl-pipe-bash installer has been replaced.${NC}"
|
||||||
|
echo ""
|
||||||
# Check Node.js
|
echo -e "${GREEN}Install claude-mem with a single command:${NC}"
|
||||||
if ! command -v node &> /dev/null; then
|
echo ""
|
||||||
error "Node.js is required but not found. Install from https://nodejs.org"
|
echo -e " ${CYAN}npx claude-mem install${NC}"
|
||||||
fi
|
echo ""
|
||||||
|
echo -e "This requires Node.js >= 18. Get it from ${CYAN}https://nodejs.org${NC}"
|
||||||
NODE_VERSION=$(node -v | sed 's/v//')
|
echo ""
|
||||||
NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1)
|
echo -e "For more info, visit: ${CYAN}https://docs.claude-mem.ai/installation${NC}"
|
||||||
if [ "$NODE_MAJOR" -lt 18 ]; then
|
echo ""
|
||||||
error "Node.js >= 18 required. Current: v${NODE_VERSION}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "claude-mem installer (Node.js v${NODE_VERSION})"
|
|
||||||
|
|
||||||
# Create temp file for installer
|
|
||||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/claude-mem-installer.XXXXXX.mjs")
|
|
||||||
|
|
||||||
# Cleanup on exit
|
|
||||||
cleanup() {
|
|
||||||
rm -f "$TMPFILE"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# Download installer
|
|
||||||
info "Downloading installer..."
|
|
||||||
if command -v curl &> /dev/null; then
|
|
||||||
curl -fsSL "$INSTALLER_URL" -o "$TMPFILE"
|
|
||||||
elif command -v wget &> /dev/null; then
|
|
||||||
wget -q "$INSTALLER_URL" -O "$TMPFILE"
|
|
||||||
else
|
|
||||||
error "curl or wget required to download installer"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run installer with TTY access
|
|
||||||
# When piped (curl | bash), stdin is the script. We need to reconnect to the terminal.
|
|
||||||
if [ -t 0 ]; then
|
|
||||||
# Already have TTY (script was downloaded and run directly)
|
|
||||||
node "$TMPFILE" "$@"
|
|
||||||
else
|
|
||||||
# Piped execution -- reconnect stdin to terminal
|
|
||||||
node "$TMPFILE" "$@" </dev/tty
|
|
||||||
fi
|
|
||||||
|
|||||||
+13
-2103
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
import { build } from 'esbuild';
|
|
||||||
|
|
||||||
await build({
|
|
||||||
entryPoints: ['src/index.ts'],
|
|
||||||
bundle: true,
|
|
||||||
format: 'esm',
|
|
||||||
platform: 'node',
|
|
||||||
target: 'node18',
|
|
||||||
outfile: 'dist/index.js',
|
|
||||||
banner: {
|
|
||||||
js: '#!/usr/bin/env node',
|
|
||||||
},
|
|
||||||
external: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Build complete: dist/index.js');
|
|
||||||
Vendored
-2107
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "claude-mem-installer",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"bin": { "claude-mem-installer": "./dist/index.js" },
|
|
||||||
"files": ["dist"],
|
|
||||||
"scripts": {
|
|
||||||
"build": "node build.mjs",
|
|
||||||
"dev": "node build.mjs && node dist/index.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@clack/prompts": "^1.0.1",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"esbuild": "^0.24.0",
|
|
||||||
"typescript": "^5.7.0",
|
|
||||||
"@types/node": "^22.0.0"
|
|
||||||
},
|
|
||||||
"engines": { "node": ">=18.0.0" }
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import { runWelcome } from './steps/welcome.js';
|
|
||||||
import { runDependencyChecks } from './steps/dependencies.js';
|
|
||||||
import { runIdeSelection } from './steps/ide-selection.js';
|
|
||||||
import { runProviderConfiguration } from './steps/provider.js';
|
|
||||||
import { runSettingsConfiguration } from './steps/settings.js';
|
|
||||||
import { writeSettings } from './utils/settings-writer.js';
|
|
||||||
import { runInstallation } from './steps/install.js';
|
|
||||||
import { runWorkerStartup } from './steps/worker.js';
|
|
||||||
import { runCompletion } from './steps/complete.js';
|
|
||||||
|
|
||||||
async function runInstaller(): Promise<void> {
|
|
||||||
if (!process.stdin.isTTY) {
|
|
||||||
console.error('Error: This installer requires an interactive terminal.');
|
|
||||||
console.error('Run directly: npx claude-mem-installer');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const installMode = await runWelcome();
|
|
||||||
|
|
||||||
// Dependency checks (all modes)
|
|
||||||
await runDependencyChecks();
|
|
||||||
|
|
||||||
// IDE and provider selection
|
|
||||||
const selectedIDEs = await runIdeSelection();
|
|
||||||
const providerConfig = await runProviderConfiguration();
|
|
||||||
|
|
||||||
// Settings configuration
|
|
||||||
const settingsConfig = await runSettingsConfiguration();
|
|
||||||
|
|
||||||
// Write settings file
|
|
||||||
writeSettings(providerConfig, settingsConfig);
|
|
||||||
p.log.success('Settings saved.');
|
|
||||||
|
|
||||||
// Installation (fresh or upgrade)
|
|
||||||
if (installMode !== 'configure') {
|
|
||||||
await runInstallation(selectedIDEs);
|
|
||||||
await runWorkerStartup(settingsConfig.workerPort, settingsConfig.dataDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completion summary
|
|
||||||
runCompletion(providerConfig, settingsConfig, selectedIDEs);
|
|
||||||
}
|
|
||||||
|
|
||||||
runInstaller().catch((error) => {
|
|
||||||
p.cancel('Installation failed.');
|
|
||||||
console.error(error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import type { ProviderConfig } from './provider.js';
|
|
||||||
import type { SettingsConfig } from './settings.js';
|
|
||||||
import type { IDE } from './ide-selection.js';
|
|
||||||
|
|
||||||
function getProviderLabel(config: ProviderConfig): string {
|
|
||||||
switch (config.provider) {
|
|
||||||
case 'claude':
|
|
||||||
return config.claudeAuthMethod === 'api' ? 'Claude (API Key)' : 'Claude (CLI subscription)';
|
|
||||||
case 'gemini':
|
|
||||||
return `Gemini (${config.model ?? 'gemini-2.5-flash-lite'})`;
|
|
||||||
case 'openrouter':
|
|
||||||
return `OpenRouter (${config.model ?? 'xiaomi/mimo-v2-flash:free'})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIDELabels(ides: IDE[]): string {
|
|
||||||
return ides.map((ide) => {
|
|
||||||
switch (ide) {
|
|
||||||
case 'claude-code': return 'Claude Code';
|
|
||||||
case 'cursor': return 'Cursor';
|
|
||||||
}
|
|
||||||
}).join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runCompletion(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
selectedIDEs: IDE[],
|
|
||||||
): void {
|
|
||||||
const summaryLines = [
|
|
||||||
`Provider: ${pc.cyan(getProviderLabel(providerConfig))}`,
|
|
||||||
`IDEs: ${pc.cyan(getIDELabels(selectedIDEs))}`,
|
|
||||||
`Data dir: ${pc.cyan(settingsConfig.dataDir)}`,
|
|
||||||
`Port: ${pc.cyan(settingsConfig.workerPort)}`,
|
|
||||||
`Chroma: ${settingsConfig.chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
p.note(summaryLines.join('\n'), 'Configuration Summary');
|
|
||||||
|
|
||||||
const nextStepsLines: string[] = [];
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('claude-code')) {
|
|
||||||
nextStepsLines.push('Open Claude Code and start a conversation — memory is automatic!');
|
|
||||||
}
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
nextStepsLines.push('Open Cursor — hooks are active in your projects.');
|
|
||||||
}
|
|
||||||
nextStepsLines.push(`View your memories: ${pc.underline(`http://localhost:${settingsConfig.workerPort}`)}`);
|
|
||||||
nextStepsLines.push(`Search past work: use ${pc.bold('/mem-search')} in Claude Code`);
|
|
||||||
|
|
||||||
p.note(nextStepsLines.join('\n'), 'Next Steps');
|
|
||||||
|
|
||||||
p.outro(pc.green('claude-mem installed successfully!'));
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { findBinary, compareVersions, installBun, installUv } from '../utils/dependencies.js';
|
|
||||||
import { detectOS } from '../utils/system.js';
|
|
||||||
|
|
||||||
const BUN_EXTRA_PATHS = ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun'];
|
|
||||||
const UV_EXTRA_PATHS = ['~/.local/bin/uv', '~/.cargo/bin/uv'];
|
|
||||||
|
|
||||||
interface DependencyStatus {
|
|
||||||
nodeOk: boolean;
|
|
||||||
gitOk: boolean;
|
|
||||||
bunOk: boolean;
|
|
||||||
uvOk: boolean;
|
|
||||||
bunPath: string | null;
|
|
||||||
uvPath: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDependencyChecks(): Promise<DependencyStatus> {
|
|
||||||
const status: DependencyStatus = {
|
|
||||||
nodeOk: false,
|
|
||||||
gitOk: false,
|
|
||||||
bunOk: false,
|
|
||||||
uvOk: false,
|
|
||||||
bunPath: null,
|
|
||||||
uvPath: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await p.tasks([
|
|
||||||
{
|
|
||||||
title: 'Checking Node.js',
|
|
||||||
task: async () => {
|
|
||||||
const version = process.version.slice(1); // remove 'v'
|
|
||||||
if (compareVersions(version, '18.0.0')) {
|
|
||||||
status.nodeOk = true;
|
|
||||||
return `Node.js ${process.version} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `Node.js ${process.version} — requires >= 18.0.0 ${pc.red('✗')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking git',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('git');
|
|
||||||
if (info.found) {
|
|
||||||
status.gitOk = true;
|
|
||||||
return `git ${info.version ?? ''} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `git not found ${pc.red('✗')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking Bun',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('bun', BUN_EXTRA_PATHS);
|
|
||||||
if (info.found && info.version && compareVersions(info.version, '1.1.14')) {
|
|
||||||
status.bunOk = true;
|
|
||||||
status.bunPath = info.path;
|
|
||||||
return `Bun ${info.version} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
if (info.found && info.version) {
|
|
||||||
return `Bun ${info.version} — requires >= 1.1.14 ${pc.yellow('⚠')}`;
|
|
||||||
}
|
|
||||||
return `Bun not found ${pc.yellow('⚠')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Checking uv',
|
|
||||||
task: async () => {
|
|
||||||
const info = findBinary('uv', UV_EXTRA_PATHS);
|
|
||||||
if (info.found) {
|
|
||||||
status.uvOk = true;
|
|
||||||
status.uvPath = info.path;
|
|
||||||
return `uv ${info.version ?? ''} ${pc.green('✓')}`;
|
|
||||||
}
|
|
||||||
return `uv not found ${pc.yellow('⚠')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Handle missing dependencies
|
|
||||||
if (!status.gitOk) {
|
|
||||||
const os = detectOS();
|
|
||||||
p.log.error('git is required but not found.');
|
|
||||||
if (os === 'macos') {
|
|
||||||
p.log.info('Install with: xcode-select --install');
|
|
||||||
} else if (os === 'linux') {
|
|
||||||
p.log.info('Install with: sudo apt install git (or your distro equivalent)');
|
|
||||||
} else {
|
|
||||||
p.log.info('Download from: https://git-scm.com/downloads');
|
|
||||||
}
|
|
||||||
p.cancel('Please install git and try again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.nodeOk) {
|
|
||||||
p.log.error(`Node.js >= 18.0.0 is required. Current: ${process.version}`);
|
|
||||||
p.cancel('Please upgrade Node.js and try again.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.bunOk) {
|
|
||||||
const shouldInstall = await p.confirm({
|
|
||||||
message: 'Bun is required but not found. Install it now?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(shouldInstall)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldInstall) {
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Installing Bun...');
|
|
||||||
try {
|
|
||||||
installBun();
|
|
||||||
const recheck = findBinary('bun', BUN_EXTRA_PATHS);
|
|
||||||
if (recheck.found) {
|
|
||||||
status.bunOk = true;
|
|
||||||
status.bunPath = recheck.path;
|
|
||||||
s.stop(`Bun installed ${pc.green('✓')}`);
|
|
||||||
} else {
|
|
||||||
s.stop(`Bun installed but not found in PATH. You may need to restart your shell.`);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
s.stop(`Bun installation failed. Install manually: curl -fsSL https://bun.sh/install | bash`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.log.warn('Bun is required for claude-mem. Install manually: curl -fsSL https://bun.sh/install | bash');
|
|
||||||
p.cancel('Cannot continue without Bun.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!status.uvOk) {
|
|
||||||
const shouldInstall = await p.confirm({
|
|
||||||
message: 'uv (Python package manager) is recommended for Chroma. Install it now?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(shouldInstall)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldInstall) {
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Installing uv...');
|
|
||||||
try {
|
|
||||||
installUv();
|
|
||||||
const recheck = findBinary('uv', UV_EXTRA_PATHS);
|
|
||||||
if (recheck.found) {
|
|
||||||
status.uvOk = true;
|
|
||||||
status.uvPath = recheck.path;
|
|
||||||
s.stop(`uv installed ${pc.green('✓')}`);
|
|
||||||
} else {
|
|
||||||
s.stop('uv installed but not found in PATH. You may need to restart your shell.');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
s.stop('uv installation failed. Install manually: curl -fsSL https://astral.sh/uv/install.sh | sh');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.log.warn('Skipping uv — Chroma vector search will not be available.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
|
|
||||||
export type IDE = 'claude-code' | 'cursor';
|
|
||||||
|
|
||||||
export async function runIdeSelection(): Promise<IDE[]> {
|
|
||||||
const result = await p.multiselect({
|
|
||||||
message: 'Which IDEs do you use?',
|
|
||||||
options: [
|
|
||||||
{ value: 'claude-code' as const, label: 'Claude Code', hint: 'recommended' },
|
|
||||||
{ value: 'cursor' as const, label: 'Cursor' },
|
|
||||||
// Windsurf coming soon - not yet selectable
|
|
||||||
],
|
|
||||||
initialValues: ['claude-code'],
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(result)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIDEs = result as IDE[];
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('claude-code')) {
|
|
||||||
p.log.info('Claude Code: Plugin will be registered via marketplace.');
|
|
||||||
}
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
p.log.info('Cursor: Hooks will be configured for your projects.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return selectedIDEs;
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir, tmpdir } from 'os';
|
|
||||||
import type { IDE } from './ide-selection.js';
|
|
||||||
|
|
||||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
|
||||||
const PLUGINS_DIR = join(homedir(), '.claude', 'plugins');
|
|
||||||
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
||||||
|
|
||||||
function ensureDir(directoryPath: string): void {
|
|
||||||
if (!existsSync(directoryPath)) {
|
|
||||||
mkdirSync(directoryPath, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readJsonFile(filepath: string): any {
|
|
||||||
if (!existsSync(filepath)) return {};
|
|
||||||
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeJsonFile(filepath: string, data: any): void {
|
|
||||||
ensureDir(join(filepath, '..'));
|
|
||||||
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerMarketplace(): void {
|
|
||||||
const knownMarketplacesPath = join(PLUGINS_DIR, 'known_marketplaces.json');
|
|
||||||
const knownMarketplaces = readJsonFile(knownMarketplacesPath);
|
|
||||||
|
|
||||||
knownMarketplaces['thedotmack'] = {
|
|
||||||
source: {
|
|
||||||
source: 'github',
|
|
||||||
repo: 'thedotmack/claude-mem',
|
|
||||||
},
|
|
||||||
installLocation: MARKETPLACE_DIR,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
autoUpdate: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
ensureDir(PLUGINS_DIR);
|
|
||||||
writeJsonFile(knownMarketplacesPath, knownMarketplaces);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerPlugin(version: string): void {
|
|
||||||
const installedPluginsPath = join(PLUGINS_DIR, 'installed_plugins.json');
|
|
||||||
const installedPlugins = readJsonFile(installedPluginsPath);
|
|
||||||
|
|
||||||
if (!installedPlugins.version) installedPlugins.version = 2;
|
|
||||||
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
|
||||||
|
|
||||||
const pluginCachePath = join(PLUGINS_DIR, 'cache', 'thedotmack', 'claude-mem', version);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
installedPlugins.plugins['claude-mem@thedotmack'] = [
|
|
||||||
{
|
|
||||||
scope: 'user',
|
|
||||||
installPath: pluginCachePath,
|
|
||||||
version,
|
|
||||||
installedAt: now,
|
|
||||||
lastUpdated: now,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
writeJsonFile(installedPluginsPath, installedPlugins);
|
|
||||||
|
|
||||||
// Copy built plugin to cache directory
|
|
||||||
ensureDir(pluginCachePath);
|
|
||||||
const pluginSourceDir = join(MARKETPLACE_DIR, 'plugin');
|
|
||||||
if (existsSync(pluginSourceDir)) {
|
|
||||||
cpSync(pluginSourceDir, pluginCachePath, { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enablePluginInClaudeSettings(): void {
|
|
||||||
const settings = readJsonFile(CLAUDE_SETTINGS_PATH);
|
|
||||||
|
|
||||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
|
||||||
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
|
||||||
|
|
||||||
writeJsonFile(CLAUDE_SETTINGS_PATH, settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginVersion(): string {
|
|
||||||
const pluginJsonPath = join(MARKETPLACE_DIR, 'plugin', '.claude-plugin', 'plugin.json');
|
|
||||||
if (existsSync(pluginJsonPath)) {
|
|
||||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
|
||||||
return pluginJson.version ?? '1.0.0';
|
|
||||||
}
|
|
||||||
return '1.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runInstallation(selectedIDEs: IDE[]): Promise<void> {
|
|
||||||
const tempDir = join(tmpdir(), `claude-mem-install-${Date.now()}`);
|
|
||||||
|
|
||||||
await p.tasks([
|
|
||||||
{
|
|
||||||
title: 'Cloning claude-mem repository',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Downloading latest release...');
|
|
||||||
execSync(
|
|
||||||
`git clone --depth 1 https://github.com/thedotmack/claude-mem.git "${tempDir}"`,
|
|
||||||
{ stdio: 'pipe' },
|
|
||||||
);
|
|
||||||
return `Repository cloned ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Installing dependencies',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Running npm install...');
|
|
||||||
execSync('npm install', { cwd: tempDir, stdio: 'pipe' });
|
|
||||||
return `Dependencies installed ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Building plugin',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Compiling TypeScript and bundling...');
|
|
||||||
execSync('npm run build', { cwd: tempDir, stdio: 'pipe' });
|
|
||||||
return `Plugin built ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Registering plugin',
|
|
||||||
task: async (message) => {
|
|
||||||
message('Copying files to marketplace directory...');
|
|
||||||
ensureDir(MARKETPLACE_DIR);
|
|
||||||
|
|
||||||
// Sync from cloned repo to marketplace dir, excluding .git and lock files
|
|
||||||
execSync(
|
|
||||||
`rsync -a --delete --exclude=.git --exclude=package-lock.json --exclude=bun.lock "${tempDir}/" "${MARKETPLACE_DIR}/"`,
|
|
||||||
{ stdio: 'pipe' },
|
|
||||||
);
|
|
||||||
|
|
||||||
message('Registering marketplace...');
|
|
||||||
registerMarketplace();
|
|
||||||
|
|
||||||
message('Installing marketplace dependencies...');
|
|
||||||
execSync('npm install', { cwd: MARKETPLACE_DIR, stdio: 'pipe' });
|
|
||||||
|
|
||||||
message('Registering plugin in Claude Code...');
|
|
||||||
const version = getPluginVersion();
|
|
||||||
registerPlugin(version);
|
|
||||||
|
|
||||||
message('Enabling plugin...');
|
|
||||||
enablePluginInClaudeSettings();
|
|
||||||
|
|
||||||
return `Plugin registered (v${getPluginVersion()}) ${pc.green('OK')}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Cleanup temp directory (non-critical if it fails)
|
|
||||||
try {
|
|
||||||
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
||||||
} catch {
|
|
||||||
// Temp dir will be cleaned by OS eventually
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedIDEs.includes('cursor')) {
|
|
||||||
p.log.info('Cursor hook configuration will be available after first launch.');
|
|
||||||
p.log.info('Run: claude-mem cursor-setup (coming soon)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
export type ProviderType = 'claude' | 'gemini' | 'openrouter';
|
|
||||||
export type ClaudeAuthMethod = 'cli' | 'api';
|
|
||||||
|
|
||||||
export interface ProviderConfig {
|
|
||||||
provider: ProviderType;
|
|
||||||
claudeAuthMethod?: ClaudeAuthMethod;
|
|
||||||
apiKey?: string;
|
|
||||||
model?: string;
|
|
||||||
rateLimitingEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runProviderConfiguration(): Promise<ProviderConfig> {
|
|
||||||
const provider = await p.select({
|
|
||||||
message: 'Which AI provider should claude-mem use for memory compression?',
|
|
||||||
options: [
|
|
||||||
{ value: 'claude' as const, label: 'Claude', hint: 'uses your Claude subscription' },
|
|
||||||
{ value: 'gemini' as const, label: 'Gemini', hint: 'free tier available' },
|
|
||||||
{ value: 'openrouter' as const, label: 'OpenRouter', hint: 'free models available' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(provider)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: ProviderConfig = { provider };
|
|
||||||
|
|
||||||
if (provider === 'claude') {
|
|
||||||
const authMethod = await p.select({
|
|
||||||
message: 'How should Claude authenticate?',
|
|
||||||
options: [
|
|
||||||
{ value: 'cli' as const, label: 'CLI (Max Plan subscription)', hint: 'no API key needed' },
|
|
||||||
{ value: 'api' as const, label: 'API Key', hint: 'uses Anthropic API credits' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(authMethod)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.claudeAuthMethod = authMethod;
|
|
||||||
|
|
||||||
if (authMethod === 'api') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your Anthropic API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
if (!value.startsWith('sk-ant-')) return 'Anthropic API keys start with sk-ant-';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'gemini') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your Gemini API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
|
|
||||||
const model = await p.select({
|
|
||||||
message: 'Which Gemini model?',
|
|
||||||
options: [
|
|
||||||
{ value: 'gemini-2.5-flash-lite' as const, label: 'Gemini 2.5 Flash Lite', hint: 'fastest, highest free RPM' },
|
|
||||||
{ value: 'gemini-2.5-flash' as const, label: 'Gemini 2.5 Flash', hint: 'balanced' },
|
|
||||||
{ value: 'gemini-3-flash-preview' as const, label: 'Gemini 3 Flash Preview', hint: 'latest' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(model)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.model = model;
|
|
||||||
|
|
||||||
const rateLimiting = await p.confirm({
|
|
||||||
message: 'Enable rate limiting? (recommended for free tier)',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(rateLimiting)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.rateLimitingEnabled = rateLimiting;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider === 'openrouter') {
|
|
||||||
const apiKey = await p.password({
|
|
||||||
message: 'Enter your OpenRouter API key:',
|
|
||||||
validate: (value) => {
|
|
||||||
if (!value || value.trim().length === 0) return 'API key is required';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(apiKey)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.apiKey = apiKey;
|
|
||||||
|
|
||||||
const model = await p.text({
|
|
||||||
message: 'Which OpenRouter model?',
|
|
||||||
defaultValue: 'xiaomi/mimo-v2-flash:free',
|
|
||||||
placeholder: 'xiaomi/mimo-v2-flash:free',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(model)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.model = model;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
|
|
||||||
export interface SettingsConfig {
|
|
||||||
workerPort: string;
|
|
||||||
dataDir: string;
|
|
||||||
contextObservations: string;
|
|
||||||
logLevel: string;
|
|
||||||
pythonVersion: string;
|
|
||||||
chromaEnabled: boolean;
|
|
||||||
chromaMode?: 'local' | 'remote';
|
|
||||||
chromaHost?: string;
|
|
||||||
chromaPort?: string;
|
|
||||||
chromaSsl?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSettingsConfiguration(): Promise<SettingsConfig> {
|
|
||||||
const useDefaults = await p.confirm({
|
|
||||||
message: 'Use default settings? (recommended for most users)',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(useDefaults)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useDefaults) {
|
|
||||||
return {
|
|
||||||
workerPort: '37777',
|
|
||||||
dataDir: '~/.claude-mem',
|
|
||||||
contextObservations: '50',
|
|
||||||
logLevel: 'INFO',
|
|
||||||
pythonVersion: '3.13',
|
|
||||||
chromaEnabled: true,
|
|
||||||
chromaMode: 'local',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom settings
|
|
||||||
const workerPort = await p.text({
|
|
||||||
message: 'Worker service port:',
|
|
||||||
defaultValue: '37777',
|
|
||||||
placeholder: '37777',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const port = parseInt(value, 10);
|
|
||||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
||||||
return 'Port must be between 1024 and 65535';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(workerPort)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const dataDir = await p.text({
|
|
||||||
message: 'Data directory:',
|
|
||||||
defaultValue: '~/.claude-mem',
|
|
||||||
placeholder: '~/.claude-mem',
|
|
||||||
});
|
|
||||||
if (p.isCancel(dataDir)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const contextObservations = await p.text({
|
|
||||||
message: 'Number of context observations per session:',
|
|
||||||
defaultValue: '50',
|
|
||||||
placeholder: '50',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const num = parseInt(value, 10);
|
|
||||||
if (isNaN(num) || num < 1 || num > 200) {
|
|
||||||
return 'Must be between 1 and 200';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(contextObservations)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const logLevel = await p.select({
|
|
||||||
message: 'Log level:',
|
|
||||||
options: [
|
|
||||||
{ value: 'DEBUG', label: 'DEBUG', hint: 'verbose' },
|
|
||||||
{ value: 'INFO', label: 'INFO', hint: 'default' },
|
|
||||||
{ value: 'WARN', label: 'WARN' },
|
|
||||||
{ value: 'ERROR', label: 'ERROR', hint: 'errors only' },
|
|
||||||
],
|
|
||||||
initialValue: 'INFO',
|
|
||||||
});
|
|
||||||
if (p.isCancel(logLevel)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const pythonVersion = await p.text({
|
|
||||||
message: 'Python version (for Chroma):',
|
|
||||||
defaultValue: '3.13',
|
|
||||||
placeholder: '3.13',
|
|
||||||
});
|
|
||||||
if (p.isCancel(pythonVersion)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
const chromaEnabled = await p.confirm({
|
|
||||||
message: 'Enable Chroma vector search?',
|
|
||||||
initialValue: true,
|
|
||||||
});
|
|
||||||
if (p.isCancel(chromaEnabled)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
|
|
||||||
let chromaMode: 'local' | 'remote' | undefined;
|
|
||||||
let chromaHost: string | undefined;
|
|
||||||
let chromaPort: string | undefined;
|
|
||||||
let chromaSsl: boolean | undefined;
|
|
||||||
|
|
||||||
if (chromaEnabled) {
|
|
||||||
const mode = await p.select({
|
|
||||||
message: 'Chroma mode:',
|
|
||||||
options: [
|
|
||||||
{ value: 'local' as const, label: 'Local', hint: 'starts local Chroma server' },
|
|
||||||
{ value: 'remote' as const, label: 'Remote', hint: 'connect to existing server' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
if (p.isCancel(mode)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaMode = mode;
|
|
||||||
|
|
||||||
if (mode === 'remote') {
|
|
||||||
const host = await p.text({
|
|
||||||
message: 'Chroma host:',
|
|
||||||
defaultValue: '127.0.0.1',
|
|
||||||
placeholder: '127.0.0.1',
|
|
||||||
});
|
|
||||||
if (p.isCancel(host)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaHost = host;
|
|
||||||
|
|
||||||
const port = await p.text({
|
|
||||||
message: 'Chroma port:',
|
|
||||||
defaultValue: '8000',
|
|
||||||
placeholder: '8000',
|
|
||||||
validate: (value = '') => {
|
|
||||||
const portNum = parseInt(value, 10);
|
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) return 'Port must be between 1 and 65535';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (p.isCancel(port)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaPort = port;
|
|
||||||
|
|
||||||
const ssl = await p.confirm({
|
|
||||||
message: 'Use SSL for Chroma connection?',
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
if (p.isCancel(ssl)) { p.cancel('Installation cancelled.'); process.exit(0); }
|
|
||||||
chromaSsl = ssl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: SettingsConfig = {
|
|
||||||
workerPort,
|
|
||||||
dataDir,
|
|
||||||
contextObservations,
|
|
||||||
logLevel,
|
|
||||||
pythonVersion,
|
|
||||||
chromaEnabled,
|
|
||||||
chromaMode,
|
|
||||||
chromaHost,
|
|
||||||
chromaPort,
|
|
||||||
chromaSsl,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show summary
|
|
||||||
const summaryLines = [
|
|
||||||
`Worker port: ${pc.cyan(workerPort)}`,
|
|
||||||
`Data directory: ${pc.cyan(dataDir)}`,
|
|
||||||
`Context observations: ${pc.cyan(contextObservations)}`,
|
|
||||||
`Log level: ${pc.cyan(logLevel)}`,
|
|
||||||
`Python version: ${pc.cyan(pythonVersion)}`,
|
|
||||||
`Chroma: ${chromaEnabled ? pc.green('enabled') : pc.dim('disabled')}`,
|
|
||||||
];
|
|
||||||
if (chromaEnabled && chromaMode) {
|
|
||||||
summaryLines.push(`Chroma mode: ${pc.cyan(chromaMode)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
p.note(summaryLines.join('\n'), 'Settings Summary');
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { existsSync } from 'fs';
|
|
||||||
import { expandHome } from '../utils/system.js';
|
|
||||||
|
|
||||||
export type InstallMode = 'fresh' | 'upgrade' | 'configure';
|
|
||||||
|
|
||||||
export async function runWelcome(): Promise<InstallMode> {
|
|
||||||
p.intro(pc.bgCyan(pc.black(' claude-mem installer ')));
|
|
||||||
|
|
||||||
p.log.info(`Version: 1.0.0`);
|
|
||||||
p.log.info(`Platform: ${process.platform} (${process.arch})`);
|
|
||||||
|
|
||||||
const settingsExist = existsSync(expandHome('~/.claude-mem/settings.json'));
|
|
||||||
const pluginExist = existsSync(expandHome('~/.claude/plugins/marketplaces/thedotmack/'));
|
|
||||||
|
|
||||||
const alreadyInstalled = settingsExist && pluginExist;
|
|
||||||
|
|
||||||
if (alreadyInstalled) {
|
|
||||||
p.log.warn('Existing claude-mem installation detected.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const installMode = await p.select({
|
|
||||||
message: 'What would you like to do?',
|
|
||||||
options: alreadyInstalled
|
|
||||||
? [
|
|
||||||
{ value: 'upgrade' as const, label: 'Upgrade', hint: 'update to latest version' },
|
|
||||||
{ value: 'configure' as const, label: 'Configure', hint: 'change settings only' },
|
|
||||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'reinstall from scratch' },
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{ value: 'fresh' as const, label: 'Fresh Install', hint: 'recommended' },
|
|
||||||
{ value: 'configure' as const, label: 'Configure Only', hint: 'set up settings without installing' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(installMode)) {
|
|
||||||
p.cancel('Installation cancelled.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return installMode;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import * as p from '@clack/prompts';
|
|
||||||
import pc from 'picocolors';
|
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import { expandHome } from '../utils/system.js';
|
|
||||||
import { findBinary } from '../utils/dependencies.js';
|
|
||||||
|
|
||||||
const MARKETPLACE_DIR = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
|
||||||
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 1000;
|
|
||||||
const HEALTH_CHECK_MAX_ATTEMPTS = 30;
|
|
||||||
|
|
||||||
async function pollHealthEndpoint(port: string, maxAttempts: number = HEALTH_CHECK_MAX_ATTEMPTS): Promise<boolean> {
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
|
||||||
if (response.ok) return true;
|
|
||||||
} catch {
|
|
||||||
// Expected during startup — worker not listening yet
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, HEALTH_CHECK_INTERVAL_MS));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runWorkerStartup(workerPort: string, dataDir: string): Promise<void> {
|
|
||||||
const bunInfo = findBinary('bun', ['~/.bun/bin/bun', '/usr/local/bin/bun', '/opt/homebrew/bin/bun']);
|
|
||||||
|
|
||||||
if (!bunInfo.found || !bunInfo.path) {
|
|
||||||
p.log.error('Bun is required to start the worker but was not found.');
|
|
||||||
p.log.info('Install Bun: curl -fsSL https://bun.sh/install | bash');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workerScript = join(MARKETPLACE_DIR, 'plugin', 'scripts', 'worker-service.cjs');
|
|
||||||
const expandedDataDir = expandHome(dataDir);
|
|
||||||
const logPath = join(expandedDataDir, 'logs');
|
|
||||||
|
|
||||||
const s = p.spinner();
|
|
||||||
s.start('Starting worker service...');
|
|
||||||
|
|
||||||
// Start worker as a detached background process
|
|
||||||
const child = spawn(bunInfo.path, [workerScript], {
|
|
||||||
cwd: MARKETPLACE_DIR,
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
CLAUDE_MEM_WORKER_PORT: workerPort,
|
|
||||||
CLAUDE_MEM_DATA_DIR: expandedDataDir,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
child.unref();
|
|
||||||
|
|
||||||
// Poll the health endpoint until the worker is responsive
|
|
||||||
const workerIsHealthy = await pollHealthEndpoint(workerPort);
|
|
||||||
|
|
||||||
if (workerIsHealthy) {
|
|
||||||
s.stop(`Worker running on port ${pc.cyan(workerPort)} ${pc.green('OK')}`);
|
|
||||||
} else {
|
|
||||||
s.stop(`Worker may still be starting. Check logs at: ${logPath}`);
|
|
||||||
p.log.warn('Health check timed out. The worker might need more time to initialize.');
|
|
||||||
p.log.info(`Check status: curl http://127.0.0.1:${workerPort}/api/health`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { existsSync } from 'fs';
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { commandExists, runCommand, expandHome, detectOS } from './system.js';
|
|
||||||
|
|
||||||
export interface BinaryInfo {
|
|
||||||
found: boolean;
|
|
||||||
path: string | null;
|
|
||||||
version: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findBinary(name: string, extraPaths: string[] = []): BinaryInfo {
|
|
||||||
// Check PATH first
|
|
||||||
if (commandExists(name)) {
|
|
||||||
const result = runCommand('which', [name]);
|
|
||||||
const versionResult = runCommand(name, ['--version']);
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
path: result.stdout,
|
|
||||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check extra known locations
|
|
||||||
for (const extraPath of extraPaths) {
|
|
||||||
const fullPath = expandHome(extraPath);
|
|
||||||
if (existsSync(fullPath)) {
|
|
||||||
const versionResult = runCommand(fullPath, ['--version']);
|
|
||||||
return {
|
|
||||||
found: true,
|
|
||||||
path: fullPath,
|
|
||||||
version: parseVersion(versionResult.stdout) || parseVersion(versionResult.stderr),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { found: false, path: null, version: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVersion(output: string): string | null {
|
|
||||||
if (!output) return null;
|
|
||||||
const match = output.match(/(\d+\.\d+(\.\d+)?)/);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compareVersions(current: string, minimum: string): boolean {
|
|
||||||
const currentParts = current.split('.').map(Number);
|
|
||||||
const minimumParts = minimum.split('.').map(Number);
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.max(currentParts.length, minimumParts.length); i++) {
|
|
||||||
const a = currentParts[i] || 0;
|
|
||||||
const b = minimumParts[i] || 0;
|
|
||||||
if (a > b) return true;
|
|
||||||
if (a < b) return false;
|
|
||||||
}
|
|
||||||
return true; // equal
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installBun(): void {
|
|
||||||
const os = detectOS();
|
|
||||||
if (os === 'windows') {
|
|
||||||
execSync('powershell -c "irm bun.sh/install.ps1 | iex"', { stdio: 'inherit' });
|
|
||||||
} else {
|
|
||||||
execSync('curl -fsSL https://bun.sh/install | bash', { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function installUv(): void {
|
|
||||||
const os = detectOS();
|
|
||||||
if (os === 'windows') {
|
|
||||||
execSync('powershell -c "irm https://astral.sh/uv/install.ps1 | iex"', { stdio: 'inherit' });
|
|
||||||
} else {
|
|
||||||
execSync('curl -fsSL https://astral.sh/uv/install.sh | sh', { stdio: 'inherit' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import type { ProviderConfig } from '../steps/provider.js';
|
|
||||||
import type { SettingsConfig } from '../steps/settings.js';
|
|
||||||
|
|
||||||
export function expandDataDir(dataDir: string): string {
|
|
||||||
if (dataDir.startsWith('~')) {
|
|
||||||
return join(homedir(), dataDir.slice(1));
|
|
||||||
}
|
|
||||||
return dataDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSettingsObject(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
): Record<string, string> {
|
|
||||||
const settings: Record<string, string> = {
|
|
||||||
CLAUDE_MEM_WORKER_PORT: settingsConfig.workerPort,
|
|
||||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
|
||||||
CLAUDE_MEM_DATA_DIR: expandDataDir(settingsConfig.dataDir),
|
|
||||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: settingsConfig.contextObservations,
|
|
||||||
CLAUDE_MEM_LOG_LEVEL: settingsConfig.logLevel,
|
|
||||||
CLAUDE_MEM_PYTHON_VERSION: settingsConfig.pythonVersion,
|
|
||||||
CLAUDE_MEM_PROVIDER: providerConfig.provider,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Provider-specific settings
|
|
||||||
if (providerConfig.provider === 'claude') {
|
|
||||||
settings.CLAUDE_MEM_CLAUDE_AUTH_METHOD = providerConfig.claudeAuthMethod ?? 'cli';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.provider === 'gemini') {
|
|
||||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_GEMINI_API_KEY = providerConfig.apiKey;
|
|
||||||
if (providerConfig.model) settings.CLAUDE_MEM_GEMINI_MODEL = providerConfig.model;
|
|
||||||
settings.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED = providerConfig.rateLimitingEnabled !== false ? 'true' : 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (providerConfig.provider === 'openrouter') {
|
|
||||||
if (providerConfig.apiKey) settings.CLAUDE_MEM_OPENROUTER_API_KEY = providerConfig.apiKey;
|
|
||||||
if (providerConfig.model) settings.CLAUDE_MEM_OPENROUTER_MODEL = providerConfig.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chroma settings
|
|
||||||
if (settingsConfig.chromaEnabled) {
|
|
||||||
settings.CLAUDE_MEM_CHROMA_MODE = settingsConfig.chromaMode ?? 'local';
|
|
||||||
if (settingsConfig.chromaMode === 'remote') {
|
|
||||||
if (settingsConfig.chromaHost) settings.CLAUDE_MEM_CHROMA_HOST = settingsConfig.chromaHost;
|
|
||||||
if (settingsConfig.chromaPort) settings.CLAUDE_MEM_CHROMA_PORT = settingsConfig.chromaPort;
|
|
||||||
if (settingsConfig.chromaSsl !== undefined) settings.CLAUDE_MEM_CHROMA_SSL = String(settingsConfig.chromaSsl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeSettings(
|
|
||||||
providerConfig: ProviderConfig,
|
|
||||||
settingsConfig: SettingsConfig,
|
|
||||||
): void {
|
|
||||||
const dataDir = expandDataDir(settingsConfig.dataDir);
|
|
||||||
const settingsPath = join(dataDir, 'settings.json');
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
if (!existsSync(dataDir)) {
|
|
||||||
mkdirSync(dataDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge with existing settings if upgrading
|
|
||||||
let existingSettings: Record<string, string> = {};
|
|
||||||
if (existsSync(settingsPath)) {
|
|
||||||
const raw = readFileSync(settingsPath, 'utf-8');
|
|
||||||
existingSettings = JSON.parse(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newSettings = buildSettingsObject(providerConfig, settingsConfig);
|
|
||||||
|
|
||||||
// Merge: new settings override existing ones
|
|
||||||
const merged = { ...existingSettings, ...newSettings };
|
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { execSync } from 'child_process';
|
|
||||||
import { homedir } from 'os';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
export type OSType = 'macos' | 'linux' | 'windows';
|
|
||||||
|
|
||||||
export function detectOS(): OSType {
|
|
||||||
switch (process.platform) {
|
|
||||||
case 'darwin': return 'macos';
|
|
||||||
case 'win32': return 'windows';
|
|
||||||
default: return 'linux';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commandExists(command: string): boolean {
|
|
||||||
try {
|
|
||||||
execSync(`which ${command}`, { stdio: 'pipe' });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandResult {
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
exitCode: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runCommand(command: string, args: string[] = []): CommandResult {
|
|
||||||
try {
|
|
||||||
const fullCommand = [command, ...args].join(' ');
|
|
||||||
const stdout = execSync(fullCommand, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
||||||
return { stdout: stdout.trim(), stderr: '', exitCode: 0 };
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
stdout: error.stdout?.toString().trim() ?? '',
|
|
||||||
stderr: error.stderr?.toString().trim() ?? '',
|
|
||||||
exitCode: error.status ?? 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function expandHome(filepath: string): string {
|
|
||||||
if (filepath.startsWith('~')) {
|
|
||||||
return join(homedir(), filepath.slice(1));
|
|
||||||
}
|
|
||||||
return filepath;
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "ESNext",
|
|
||||||
"target": "ES2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"strict": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": false,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"forceConsistentCasingInFileNames": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
+16
-1
@@ -26,6 +26,9 @@
|
|||||||
"url": "https://github.com/thedotmack/claude-mem/issues"
|
"url": "https://github.com/thedotmack/claude-mem/issues"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"claude-mem": "./dist/npx-cli/index.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
@@ -39,7 +42,17 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"plugin"
|
"plugin/.claude-plugin",
|
||||||
|
"plugin/CLAUDE.md",
|
||||||
|
"plugin/package.json",
|
||||||
|
"plugin/hooks",
|
||||||
|
"plugin/modes",
|
||||||
|
"plugin/scripts/*.js",
|
||||||
|
"plugin/scripts/*.cjs",
|
||||||
|
"plugin/scripts/CLAUDE.md",
|
||||||
|
"plugin/skills",
|
||||||
|
"plugin/ui",
|
||||||
|
"openclaw"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0",
|
"node": ">=18.0.0",
|
||||||
@@ -97,12 +110,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
|
||||||
|
"@clack/prompts": "^0.9.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"dompurify": "^3.3.1",
|
"dompurify": "^3.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
|||||||
@@ -74,8 +74,8 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}},()=>process.exit(0));r.on('error',()=>process.exit(0));r.end(JSON.stringify({contentSessionId:s}));setTimeout(()=>process.exit(0),3000)}catch{process.exit(0)}})\"",
|
"command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const{sessionId:s}=JSON.parse(d);if(!s){process.exit(0)}const r=require('http').request({hostname:'127.0.0.1',port:37777,path:'/api/sessions/complete',method:'POST',headers:{'Content-Type':'application/json'}});r.on('error',()=>{});r.end(JSON.stringify({contentSessionId:s}));process.exit(0)}catch{process.exit(0)}})\"",
|
||||||
"timeout": 5
|
"timeout": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+257
-195
File diff suppressed because one or more lines are too long
+11
-11
File diff suppressed because one or more lines are too long
+94
-1
@@ -187,6 +187,89 @@ async function buildHooks() {
|
|||||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||||
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
|
// Build NPX CLI (pure Node.js — no Bun dependency)
|
||||||
|
console.log(`\n🔧 Building NPX CLI...`);
|
||||||
|
const npxCliOutDir = 'dist/npx-cli';
|
||||||
|
if (!fs.existsSync(npxCliOutDir)) {
|
||||||
|
fs.mkdirSync(npxCliOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/npx-cli/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${npxCliOutDir}/index.js`,
|
||||||
|
banner: { js: '#!/usr/bin/env node' },
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
'buffer', 'querystring', 'readline', 'tty', 'assert',
|
||||||
|
],
|
||||||
|
define: {
|
||||||
|
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make NPX CLI executable
|
||||||
|
fs.chmodSync(`${npxCliOutDir}/index.js`, 0o755);
|
||||||
|
const npxCliStats = fs.statSync(`${npxCliOutDir}/index.js`);
|
||||||
|
console.log(`✓ npx-cli built (${(npxCliStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
|
// Build OpenClaw plugin (self-contained, only Node builtins external)
|
||||||
|
if (fs.existsSync('openclaw/src/index.ts')) {
|
||||||
|
console.log(`\n🔧 Building OpenClaw plugin...`);
|
||||||
|
const openclawOutDir = 'openclaw/dist';
|
||||||
|
if (!fs.existsSync(openclawOutDir)) {
|
||||||
|
fs.mkdirSync(openclawOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['openclaw/src/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${openclawOutDir}/index.js`,
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const openclawStats = fs.statSync(`${openclawOutDir}/index.js`);
|
||||||
|
console.log(`✓ openclaw plugin built (${(openclawStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build OpenCode plugin (self-contained, Node.js ESM — Bun-compatible)
|
||||||
|
if (fs.existsSync('src/integrations/opencode-plugin/index.ts')) {
|
||||||
|
console.log(`\n🔧 Building OpenCode plugin...`);
|
||||||
|
const opencodeOutDir = 'dist/opencode-plugin';
|
||||||
|
if (!fs.existsSync(opencodeOutDir)) {
|
||||||
|
fs.mkdirSync(opencodeOutDir, { recursive: true });
|
||||||
|
}
|
||||||
|
await build({
|
||||||
|
entryPoints: ['src/integrations/opencode-plugin/index.ts'],
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
target: 'node18',
|
||||||
|
format: 'esm',
|
||||||
|
outfile: `${opencodeOutDir}/index.js`,
|
||||||
|
minify: true,
|
||||||
|
logLevel: 'error',
|
||||||
|
external: [
|
||||||
|
'fs', 'fs/promises', 'path', 'os', 'child_process', 'url',
|
||||||
|
'crypto', 'http', 'https', 'net', 'stream', 'util', 'events',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const opencodeStats = fs.statSync(`${opencodeOutDir}/index.js`);
|
||||||
|
console.log(`✓ opencode plugin built (${(opencodeStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify critical distribution files exist (skills are source files, not build outputs)
|
// Verify critical distribution files exist (skills are source files, not build outputs)
|
||||||
console.log('\n📋 Verifying distribution files...');
|
console.log('\n📋 Verifying distribution files...');
|
||||||
const requiredDistributionFiles = [
|
const requiredDistributionFiles = [
|
||||||
@@ -202,11 +285,21 @@ async function buildHooks() {
|
|||||||
}
|
}
|
||||||
console.log('✓ All required distribution files present');
|
console.log('✓ All required distribution files present');
|
||||||
|
|
||||||
console.log('\n✅ Worker service, MCP server, and context generator built successfully!');
|
console.log('\n✅ All build targets compiled successfully!');
|
||||||
console.log(` Output: ${hooksDir}/`);
|
console.log(` Output: ${hooksDir}/`);
|
||||||
console.log(` - Worker: worker-service.cjs`);
|
console.log(` - Worker: worker-service.cjs`);
|
||||||
console.log(` - MCP Server: mcp-server.cjs`);
|
console.log(` - MCP Server: mcp-server.cjs`);
|
||||||
console.log(` - Context Generator: context-generator.cjs`);
|
console.log(` - Context Generator: context-generator.cjs`);
|
||||||
|
console.log(` Output: ${npxCliOutDir}/`);
|
||||||
|
console.log(` - NPX CLI: index.js`);
|
||||||
|
if (fs.existsSync('openclaw/dist/index.js')) {
|
||||||
|
console.log(` Output: openclaw/dist/`);
|
||||||
|
console.log(` - OpenClaw Plugin: index.js`);
|
||||||
|
}
|
||||||
|
if (fs.existsSync('dist/opencode-plugin/index.js')) {
|
||||||
|
console.log(` Output: dist/opencode-plugin/`);
|
||||||
|
console.log(` - OpenCode Plugin: index.js`);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('\n❌ Build failed:', error.message);
|
console.error('\n❌ Build failed:', error.message);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { claudeCodeAdapter } from './claude-code.js';
|
|||||||
import { cursorAdapter } from './cursor.js';
|
import { cursorAdapter } from './cursor.js';
|
||||||
import { geminiCliAdapter } from './gemini-cli.js';
|
import { geminiCliAdapter } from './gemini-cli.js';
|
||||||
import { rawAdapter } from './raw.js';
|
import { rawAdapter } from './raw.js';
|
||||||
|
import { windsurfAdapter } from './windsurf.js';
|
||||||
|
|
||||||
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
export function getPlatformAdapter(platform: string): PlatformAdapter {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
@@ -10,10 +11,11 @@ export function getPlatformAdapter(platform: string): PlatformAdapter {
|
|||||||
case 'cursor': return cursorAdapter;
|
case 'cursor': return cursorAdapter;
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
case 'gemini-cli': return geminiCliAdapter;
|
case 'gemini-cli': return geminiCliAdapter;
|
||||||
|
case 'windsurf': return windsurfAdapter;
|
||||||
case 'raw': return rawAdapter;
|
case 'raw': return rawAdapter;
|
||||||
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
// Codex CLI and other compatible platforms use the raw adapter (accepts both camelCase and snake_case fields)
|
||||||
default: return rawAdapter;
|
default: return rawAdapter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter };
|
export { claudeCodeAdapter, cursorAdapter, geminiCliAdapter, rawAdapter, windsurfAdapter };
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
|
|
||||||
|
// Maps Windsurf stdin format — JSON envelope with agent_action_name + tool_info payload
|
||||||
|
//
|
||||||
|
// Common envelope (all hooks):
|
||||||
|
// { agent_action_name, trajectory_id, execution_id, timestamp, tool_info: { ... } }
|
||||||
|
//
|
||||||
|
// Event-specific tool_info payloads:
|
||||||
|
// pre_user_prompt: { user_prompt: string }
|
||||||
|
// post_write_code: { file_path, edits: [{ old_string, new_string }] }
|
||||||
|
// post_run_command: { command_line, cwd }
|
||||||
|
// post_mcp_tool_use: { mcp_server_name, mcp_tool_name, mcp_tool_arguments, mcp_result }
|
||||||
|
// post_cascade_response: { response }
|
||||||
|
export const windsurfAdapter: PlatformAdapter = {
|
||||||
|
normalizeInput(raw) {
|
||||||
|
const r = (raw ?? {}) as any;
|
||||||
|
const toolInfo = r.tool_info ?? {};
|
||||||
|
const actionName: string = r.agent_action_name ?? '';
|
||||||
|
|
||||||
|
const base: NormalizedHookInput = {
|
||||||
|
sessionId: r.trajectory_id ?? r.execution_id,
|
||||||
|
cwd: toolInfo.cwd ?? process.cwd(),
|
||||||
|
platform: 'windsurf',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (actionName) {
|
||||||
|
case 'pre_user_prompt':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
prompt: toolInfo.user_prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_write_code':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: 'Write',
|
||||||
|
filePath: toolInfo.file_path,
|
||||||
|
edits: toolInfo.edits,
|
||||||
|
toolInput: {
|
||||||
|
file_path: toolInfo.file_path,
|
||||||
|
edits: toolInfo.edits,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_run_command':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
cwd: toolInfo.cwd ?? base.cwd,
|
||||||
|
toolName: 'Bash',
|
||||||
|
toolInput: { command: toolInfo.command_line },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_mcp_tool_use':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: toolInfo.mcp_tool_name ?? 'mcp_tool',
|
||||||
|
toolInput: toolInfo.mcp_tool_arguments,
|
||||||
|
toolResponse: toolInfo.mcp_result,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'post_cascade_response':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
toolName: 'cascade_response',
|
||||||
|
toolResponse: toolInfo.response,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown action — pass through what we can
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatOutput(result) {
|
||||||
|
// Windsurf exit codes: 0 = success, 2 = block (pre-hooks only)
|
||||||
|
// The CLI layer handles exit codes; here we just return a simple continue flag
|
||||||
|
return { continue: result.continue ?? true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -39,7 +39,7 @@ export const contextHandler: EventHandler = {
|
|||||||
// Pass all projects (parent + worktree if applicable) for unified timeline
|
// Pass all projects (parent + worktree if applicable) for unified timeline
|
||||||
const projectsParam = context.allProjects.join(',');
|
const projectsParam = context.allProjects.join(',');
|
||||||
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
const apiPath = `/api/context/inject?projects=${encodeURIComponent(projectsParam)}`;
|
||||||
const colorApiPath = `${apiPath}&colors=true`;
|
const colorApiPath = input.platform === 'claude-code' ? `${apiPath}&colors=true` : apiPath;
|
||||||
|
|
||||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* Summarize Handler - Stop
|
* Summarize Handler - Stop
|
||||||
*
|
*
|
||||||
* Extracted from summary-hook.ts - sends summary request to worker.
|
* Runs in the Stop hook (120s timeout, not capped like SessionEnd).
|
||||||
* Transcript parsing stays in the hook because only the hook has access to
|
* This is the ONLY place where we can reliably wait for async work.
|
||||||
* the transcript file path.
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Queue summarize request to worker
|
||||||
|
* 2. Poll worker until summary processing completes
|
||||||
|
* 3. Call /api/sessions/complete to clean up session
|
||||||
|
*
|
||||||
|
* SessionEnd (1.5s cap from Claude Code) is just a lightweight fallback —
|
||||||
|
* all real work must happen here in Stop.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
@@ -13,6 +20,8 @@ import { extractLastMessage } from '../../shared/transcript-parser.js';
|
|||||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||||
|
|
||||||
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
||||||
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120s timeout
|
||||||
|
|
||||||
export const summarizeHandler: EventHandler = {
|
export const summarizeHandler: EventHandler = {
|
||||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||||
@@ -47,7 +56,7 @@ export const summarizeHandler: EventHandler = {
|
|||||||
hasLastAssistantMessage: !!lastAssistantMessage
|
hasLastAssistantMessage: !!lastAssistantMessage
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send to worker - worker handles privacy check and database operations
|
// 1. Queue summarize request — worker returns immediately with { status: 'queued' }
|
||||||
const response = await workerHttpRequest('/api/sessions/summarize', {
|
const response = await workerHttpRequest('/api/sessions/summarize', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -59,11 +68,49 @@ export const summarizeHandler: EventHandler = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Return standard response even on failure (matches original behavior)
|
|
||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('HOOK', 'Summary request sent successfully');
|
logger.debug('HOOK', 'Summary request queued, waiting for completion');
|
||||||
|
|
||||||
|
// 2. Poll worker until pending work for this session is done.
|
||||||
|
// This keeps the Stop hook alive (120s timeout) so the SDK agent
|
||||||
|
// can finish processing the summary before SessionEnd kills the session.
|
||||||
|
const waitStart = Date.now();
|
||||||
|
while ((Date.now() - waitStart) < MAX_WAIT_FOR_SUMMARY_MS) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
try {
|
||||||
|
const statusResponse = await workerHttpRequest(`/api/sessions/status?contentSessionId=${encodeURIComponent(sessionId)}`, {
|
||||||
|
timeoutMs: 5000
|
||||||
|
});
|
||||||
|
if (statusResponse.ok) {
|
||||||
|
const status = await statusResponse.json() as { queueLength?: number };
|
||||||
|
if ((status.queueLength ?? 0) === 0) {
|
||||||
|
logger.info('HOOK', 'Summary processing complete', {
|
||||||
|
waitedMs: Date.now() - waitStart
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Worker may be busy — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Complete the session — clean up active sessions map.
|
||||||
|
// This runs here in Stop (120s timeout) instead of SessionEnd (1.5s cap)
|
||||||
|
// so it reliably fires after summary work is done.
|
||||||
|
try {
|
||||||
|
await workerHttpRequest('/api/sessions/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ contentSessionId: sessionId }),
|
||||||
|
timeoutMs: 10_000
|
||||||
|
});
|
||||||
|
logger.info('HOOK', 'Session completed in Stop hook', { contentSessionId: sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('HOOK', `Stop hook: session-complete failed: ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,11 @@ export const userMessageHandler: EventHandler = {
|
|||||||
const project = basename(input.cwd ?? process.cwd());
|
const project = basename(input.cwd ?? process.cwd());
|
||||||
|
|
||||||
// Fetch formatted context directly from worker API
|
// Fetch formatted context directly from worker API
|
||||||
|
// Only request ANSI colors for platforms that render them (claude-code)
|
||||||
|
const colorsParam = input.platform === 'claude-code' ? '&colors=true' : '';
|
||||||
try {
|
try {
|
||||||
const response = await workerHttpRequest(
|
const response = await workerHttpRequest(
|
||||||
`/api/context/inject?project=${encodeURIComponent(project)}&colors=true`
|
`/api/context/inject?project=${encodeURIComponent(project)}${colorsParam}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
export interface NormalizedHookInput {
|
export interface NormalizedHookInput {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
platform?: string; // 'claude-code' or 'cursor'
|
platform?: string; // 'claude-code', 'cursor', 'gemini-cli', etc.
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
toolInput?: unknown;
|
toolInput?: unknown;
|
||||||
@@ -10,6 +10,8 @@ export interface NormalizedHookInput {
|
|||||||
// Cursor-specific fields
|
// Cursor-specific fields
|
||||||
filePath?: string; // afterFileEdit
|
filePath?: string; // afterFileEdit
|
||||||
edits?: unknown[]; // afterFileEdit
|
edits?: unknown[]; // afterFileEdit
|
||||||
|
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HookResult {
|
export interface HookResult {
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode Plugin for claude-mem
|
||||||
|
*
|
||||||
|
* Integrates claude-mem persistent memory with OpenCode (110k+ stars).
|
||||||
|
* Runs inside OpenCode's Bun-based plugin runtime.
|
||||||
|
*
|
||||||
|
* Plugin hooks:
|
||||||
|
* - tool.execute.after: Captures tool execution observations
|
||||||
|
* - Bus events: session.created, message.updated, session.compacted,
|
||||||
|
* file.edited, session.deleted
|
||||||
|
*
|
||||||
|
* Custom tool:
|
||||||
|
* - claude_mem_search: Search memory database from within OpenCode
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Minimal type declarations for OpenCode Plugin SDK
|
||||||
|
// These match the runtime API provided by @opencode-ai/plugin
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface OpenCodeProject {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenCodePluginContext {
|
||||||
|
client: unknown;
|
||||||
|
project: OpenCodeProject;
|
||||||
|
directory: string;
|
||||||
|
worktree: string;
|
||||||
|
serverUrl: URL;
|
||||||
|
$: unknown; // BunShell
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolExecuteAfterInput {
|
||||||
|
tool: string;
|
||||||
|
sessionID: string;
|
||||||
|
callID: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolExecuteAfterOutput {
|
||||||
|
title: string;
|
||||||
|
output: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolDefinition {
|
||||||
|
description: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
execute: (args: Record<string, unknown>, context: unknown) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bus event payloads
|
||||||
|
interface SessionCreatedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
directory?: string;
|
||||||
|
project?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageUpdatedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCompactedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
summary?: string;
|
||||||
|
messageCount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileEditedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
path: string;
|
||||||
|
diff?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionDeletedEvent {
|
||||||
|
event: {
|
||||||
|
sessionID: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const WORKER_BASE_URL = "http://127.0.0.1:37777";
|
||||||
|
const MAX_TOOL_RESPONSE_LENGTH = 1000;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Worker HTTP Client
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function workerPost(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} returned ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (await response.json()) as Record<string, unknown>;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Gracefully handle ECONNREFUSED — worker may not be running
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workerPostFireAndForget(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
fetch(`${WORKER_BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker POST ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workerGetText(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_BASE_URL}${path}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[claude-mem] Worker GET ${path} returned ${response.status}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await response.text();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (!message.includes("ECONNREFUSED")) {
|
||||||
|
console.warn(`[claude-mem] Worker GET ${path} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Session tracking
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const contentSessionIdsByOpenCodeSessionId = new Map<string, string>();
|
||||||
|
|
||||||
|
const MAX_SESSION_MAP_ENTRIES = 1000;
|
||||||
|
|
||||||
|
function getOrCreateContentSessionId(openCodeSessionId: string): string {
|
||||||
|
if (!contentSessionIdsByOpenCodeSessionId.has(openCodeSessionId)) {
|
||||||
|
// Evict oldest entries when the map exceeds the cap (Map preserves insertion order)
|
||||||
|
while (contentSessionIdsByOpenCodeSessionId.size >= MAX_SESSION_MAP_ENTRIES) {
|
||||||
|
const oldestKey = contentSessionIdsByOpenCodeSessionId.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
contentSessionIdsByOpenCodeSessionId.delete(oldestKey);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentSessionIdsByOpenCodeSessionId.set(
|
||||||
|
openCodeSessionId,
|
||||||
|
`opencode-${openCodeSessionId}-${Date.now()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contentSessionIdsByOpenCodeSessionId.get(openCodeSessionId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Entry Point
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ClaudeMemPlugin = async (ctx: OpenCodePluginContext) => {
|
||||||
|
const projectName = ctx.project?.name || "opencode";
|
||||||
|
|
||||||
|
console.log(`[claude-mem] OpenCode plugin loading (project: ${projectName})`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Direct interceptor hooks
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
hooks: {
|
||||||
|
tool: {
|
||||||
|
execute: {
|
||||||
|
after: (
|
||||||
|
input: ToolExecuteAfterInput,
|
||||||
|
output: ToolExecuteAfterOutput,
|
||||||
|
) => {
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(input.sessionID);
|
||||||
|
|
||||||
|
// Truncate long tool output
|
||||||
|
let toolResponseText = output.output || "";
|
||||||
|
if (toolResponseText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||||
|
toolResponseText = toolResponseText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: input.tool,
|
||||||
|
tool_input: input.args || {},
|
||||||
|
tool_response: toolResponseText,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Bus event handlers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
event: (eventName: string, payload: unknown) => {
|
||||||
|
switch (eventName) {
|
||||||
|
case "session.created": {
|
||||||
|
const { event } = payload as SessionCreatedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/init", {
|
||||||
|
contentSessionId,
|
||||||
|
project: projectName,
|
||||||
|
prompt: "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const { event } = payload as MessageUpdatedEvent;
|
||||||
|
|
||||||
|
// Only capture assistant messages as observations
|
||||||
|
if (event.role !== "assistant") break;
|
||||||
|
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
let messageText = event.content || "";
|
||||||
|
if (messageText.length > MAX_TOOL_RESPONSE_LENGTH) {
|
||||||
|
messageText = messageText.slice(0, MAX_TOOL_RESPONSE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: "assistant_message",
|
||||||
|
tool_input: {},
|
||||||
|
tool_response: messageText,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.compacted": {
|
||||||
|
const { event } = payload as SessionCompactedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/summarize", {
|
||||||
|
contentSessionId,
|
||||||
|
last_assistant_message: event.summary || "",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "file.edited": {
|
||||||
|
const { event } = payload as FileEditedEvent;
|
||||||
|
const contentSessionId = getOrCreateContentSessionId(event.sessionID);
|
||||||
|
|
||||||
|
workerPostFireAndForget("/api/sessions/observations", {
|
||||||
|
contentSessionId,
|
||||||
|
tool_name: "file_edit",
|
||||||
|
tool_input: { path: event.path },
|
||||||
|
tool_response: event.diff
|
||||||
|
? event.diff.slice(0, MAX_TOOL_RESPONSE_LENGTH)
|
||||||
|
: `File edited: ${event.path}`,
|
||||||
|
cwd: ctx.directory,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.deleted": {
|
||||||
|
const { event } = payload as SessionDeletedEvent;
|
||||||
|
const contentSessionId = contentSessionIdsByOpenCodeSessionId.get(
|
||||||
|
event.sessionID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (contentSessionId) {
|
||||||
|
workerPostFireAndForget("/api/sessions/complete", {
|
||||||
|
contentSessionId,
|
||||||
|
});
|
||||||
|
contentSessionIdsByOpenCodeSessionId.delete(event.sessionID);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Custom tools
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
tool: {
|
||||||
|
claude_mem_search: {
|
||||||
|
description:
|
||||||
|
"Search claude-mem memory database for past observations, sessions, and context",
|
||||||
|
args: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Search query for memory observations",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async execute(
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Promise<string> {
|
||||||
|
const query = String(args.query || "");
|
||||||
|
if (!query) {
|
||||||
|
return "Please provide a search query.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await workerGetText(
|
||||||
|
`/api/search/observations?query=${encodeURIComponent(query)}&limit=10`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return "claude-mem worker is not running. Start it with: npx claude-mem start";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return `No results found for "${query}".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((item: Record<string, unknown>, index: number) => {
|
||||||
|
const title = String(item.title || item.subtitle || "Untitled");
|
||||||
|
const project = item.project ? ` [${String(item.project)}]` : "";
|
||||||
|
return `${index + 1}. ${title}${project}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
} catch {
|
||||||
|
return "Failed to parse search results.";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies ToolDefinition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClaudeMemPlugin;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* IDE Auto-Detection
|
||||||
|
*
|
||||||
|
* Detects which AI coding IDEs / tools are installed on the system by
|
||||||
|
* probing known config directories and checking for binaries in PATH.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { existsSync, readdirSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { IS_WINDOWS } from '../utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IDE type and metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface IDEInfo {
|
||||||
|
/** Machine-readable identifier. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label for display in prompts. */
|
||||||
|
label: string;
|
||||||
|
/** Whether the IDE was detected on this system. */
|
||||||
|
detected: boolean;
|
||||||
|
/** Whether claude-mem has implemented setup for this IDE. */
|
||||||
|
supported: boolean;
|
||||||
|
/** Short hint text shown in the multi-select. */
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PATH helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isCommandInPath(command: string): boolean {
|
||||||
|
try {
|
||||||
|
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||||
|
execSync(`${whichCommand} ${command}`, { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VS Code extension directory scanner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function hasVscodeExtension(extensionNameFragment: string): boolean {
|
||||||
|
const extensionsDirectory = join(homedir(), '.vscode', 'extensions');
|
||||||
|
if (!existsSync(extensionsDirectory)) return false;
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(extensionsDirectory);
|
||||||
|
return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase()));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detection map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect all known IDEs and return an array of `IDEInfo` objects.
|
||||||
|
* Each entry indicates whether the IDE was found and whether claude-mem
|
||||||
|
* currently supports setting it up.
|
||||||
|
*/
|
||||||
|
export function detectInstalledIDEs(): IDEInfo[] {
|
||||||
|
const home = homedir();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'claude-code',
|
||||||
|
label: 'Claude Code',
|
||||||
|
detected: existsSync(join(home, '.claude')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'recommended',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-cli',
|
||||||
|
label: 'Gemini CLI',
|
||||||
|
detected: existsSync(join(home, '.gemini')),
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'opencode',
|
||||||
|
label: 'OpenCode',
|
||||||
|
detected:
|
||||||
|
existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'plugin-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openclaw',
|
||||||
|
label: 'OpenClaw',
|
||||||
|
detected: existsSync(join(home, '.openclaw')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'plugin-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'windsurf',
|
||||||
|
label: 'Windsurf',
|
||||||
|
detected: existsSync(join(home, '.codeium', 'windsurf')),
|
||||||
|
supported: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'codex-cli',
|
||||||
|
label: 'Codex CLI',
|
||||||
|
detected: existsSync(join(home, '.codex')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'transcript-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cursor',
|
||||||
|
label: 'Cursor',
|
||||||
|
detected: existsSync(join(home, '.cursor')),
|
||||||
|
supported: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copilot-cli',
|
||||||
|
label: 'Copilot CLI',
|
||||||
|
detected: isCommandInPath('copilot'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'antigravity',
|
||||||
|
label: 'Antigravity',
|
||||||
|
detected: existsSync(join(home, '.gemini', 'antigravity')),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'goose',
|
||||||
|
label: 'Goose',
|
||||||
|
detected:
|
||||||
|
existsSync(join(home, '.config', 'goose')) || isCommandInPath('goose'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crush',
|
||||||
|
label: 'Crush',
|
||||||
|
detected: isCommandInPath('crush'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roo-code',
|
||||||
|
label: 'Roo Code',
|
||||||
|
detected: hasVscodeExtension('roo-code'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'warp',
|
||||||
|
label: 'Warp',
|
||||||
|
detected: existsSync(join(home, '.warp')) || isCommandInPath('warp'),
|
||||||
|
supported: true,
|
||||||
|
hint: 'MCP-based integration',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only the IDEs that were detected on this system.
|
||||||
|
*/
|
||||||
|
export function getDetectedIDEs(): IDEInfo[] {
|
||||||
|
return detectInstalledIDEs().filter((ide) => ide.detected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Install command for `npx claude-mem install`.
|
||||||
|
*
|
||||||
|
* Delegates to Claude Code's native plugin system — two commands handle
|
||||||
|
* marketplace registration, plugin installation, dependency setup, and
|
||||||
|
* settings enablement.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
|
||||||
|
export interface InstallOptions {
|
||||||
|
/** Unused — kept for CLI compat. IDE integrations are separate. */
|
||||||
|
ide?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runInstallCommand(_options: InstallOptions = {}): Promise<void> {
|
||||||
|
console.log(pc.bold('claude-mem install'));
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
'claude plugin marketplace add thedotmack/claude-mem && claude plugin install claude-mem',
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(pc.red('Installation failed.'));
|
||||||
|
console.error('Make sure Claude Code CLI is installed and on your PATH.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(pc.green('claude-mem installed successfully!'));
|
||||||
|
console.log();
|
||||||
|
console.log('Open Claude Code and start a conversation — memory is automatic.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Runtime command routing for `npx claude-mem start|stop|restart|status|search|transcript`.
|
||||||
|
*
|
||||||
|
* These commands delegate to the installed plugin's worker-service.cjs via Bun,
|
||||||
|
* or hit the worker's HTTP API directly (for `search`).
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { resolveBunBinaryPath } from '../utils/bun-resolver.js';
|
||||||
|
import { isPluginInstalled, marketplaceDirectory } from '../utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Installation guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ensureInstalledOrExit(): void {
|
||||||
|
if (!isPluginInstalled()) {
|
||||||
|
console.error(pc.red('claude-mem is not installed.'));
|
||||||
|
console.error(`Run: ${pc.bold('npx claude-mem install')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bun guard
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function resolveBunOrExit(): string {
|
||||||
|
const bunPath = resolveBunBinaryPath();
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error(pc.red('Bun not found.'));
|
||||||
|
console.error('Install Bun: https://bun.sh');
|
||||||
|
console.error('After installation, restart your terminal.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return bunPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Worker-service path
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function workerServiceScriptPath(): string {
|
||||||
|
return join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spawn helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function spawnBunWorkerCommand(command: string, extraArgs: string[] = []): void {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
const bunPath = resolveBunOrExit();
|
||||||
|
const workerScript = workerServiceScriptPath();
|
||||||
|
|
||||||
|
if (!existsSync(workerScript)) {
|
||||||
|
console.error(pc.red(`Worker script not found at: ${workerScript}`));
|
||||||
|
console.error('The installation may be corrupted. Try: npx claude-mem install');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = [workerScript, command, ...extraArgs];
|
||||||
|
|
||||||
|
const child = spawn(bunPath, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: marketplaceDirectory(),
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(pc.red(`Failed to start Bun: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (exitCode) => {
|
||||||
|
process.exit(exitCode ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function runStartCommand(): void {
|
||||||
|
spawnBunWorkerCommand('start');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStopCommand(): void {
|
||||||
|
spawnBunWorkerCommand('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runRestartCommand(): void {
|
||||||
|
spawnBunWorkerCommand('restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runStatusCommand(): void {
|
||||||
|
spawnBunWorkerCommand('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search the worker API at `GET /api/search?q=<query>`.
|
||||||
|
*/
|
||||||
|
export async function runSearchCommand(queryParts: string[]): Promise<void> {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
|
||||||
|
const query = queryParts.join(' ').trim();
|
||||||
|
if (!query) {
|
||||||
|
console.error(pc.red('Usage: npx claude-mem search <query>'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||||
|
const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(searchUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
console.error(pc.red('Search endpoint not found. Is the worker running?'));
|
||||||
|
console.error(`Try: ${pc.bold('npx claude-mem start')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.error(pc.red(`Search failed: HTTP ${response.status}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (typeof data === 'object' && data !== null) {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(data);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) {
|
||||||
|
console.error(pc.red('Worker is not running.'));
|
||||||
|
console.error(`Start it with: ${pc.bold('npx claude-mem start')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.error(pc.red(`Search failed: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the transcript watcher via Bun.
|
||||||
|
*/
|
||||||
|
export function runTranscriptWatchCommand(): void {
|
||||||
|
ensureInstalledOrExit();
|
||||||
|
const bunPath = resolveBunOrExit();
|
||||||
|
|
||||||
|
const transcriptWatcherPath = join(
|
||||||
|
marketplaceDirectory(),
|
||||||
|
'plugin',
|
||||||
|
'scripts',
|
||||||
|
'transcript-watcher.cjs',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existsSync(transcriptWatcherPath)) {
|
||||||
|
// Fall back to worker-service with transcript subcommand
|
||||||
|
spawnBunWorkerCommand('transcript', ['watch']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(bunPath, [transcriptWatcherPath, 'watch'], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: marketplaceDirectory(),
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(pc.red(`Failed to start transcript watcher: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (exitCode) => {
|
||||||
|
process.exit(exitCode ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Uninstall command for `npx claude-mem uninstall`.
|
||||||
|
*
|
||||||
|
* Removes the plugin from the marketplace directory, cache, plugin
|
||||||
|
* registrations, and Claude settings. Optionally cleans up IDE-specific
|
||||||
|
* configurations.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import * as p from '@clack/prompts';
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { existsSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import {
|
||||||
|
claudeSettingsPath,
|
||||||
|
installedPluginsPath,
|
||||||
|
isPluginInstalled,
|
||||||
|
knownMarketplacesPath,
|
||||||
|
marketplaceDirectory,
|
||||||
|
pluginsDirectory,
|
||||||
|
writeJsonFileAtomic,
|
||||||
|
} from '../utils/paths.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cleanup helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function removeMarketplaceDirectory(): boolean {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
if (existsSync(marketplaceDir)) {
|
||||||
|
rmSync(marketplaceDir, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCacheDirectory(): boolean {
|
||||||
|
const cacheDirectory = join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem');
|
||||||
|
if (existsSync(cacheDirectory)) {
|
||||||
|
rmSync(cacheDirectory, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromKnownMarketplaces(): void {
|
||||||
|
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||||
|
if (knownMarketplaces['thedotmack']) {
|
||||||
|
delete knownMarketplaces['thedotmack'];
|
||||||
|
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromInstalledPlugins(): void {
|
||||||
|
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||||
|
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
|
||||||
|
delete installedPlugins.plugins['claude-mem@thedotmack'];
|
||||||
|
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromClaudeSettings(): void {
|
||||||
|
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||||
|
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
||||||
|
delete settings.enabledPlugins['claude-mem@thedotmack'];
|
||||||
|
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runUninstallCommand(): Promise<void> {
|
||||||
|
p.intro(pc.bgRed(pc.white(' claude-mem uninstall ')));
|
||||||
|
|
||||||
|
if (!isPluginInstalled()) {
|
||||||
|
p.log.warn('claude-mem does not appear to be installed.');
|
||||||
|
|
||||||
|
// Still offer to clean up partial state
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
const shouldCleanup = await p.confirm({
|
||||||
|
message: 'Clean up any remaining registration data anyway?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(shouldCleanup) || !shouldCleanup) {
|
||||||
|
p.outro('Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.outro('Nothing to do.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (process.stdin.isTTY) {
|
||||||
|
const shouldContinue = await p.confirm({
|
||||||
|
message: 'Are you sure you want to uninstall claude-mem?',
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(shouldContinue) || !shouldContinue) {
|
||||||
|
p.cancel('Uninstall cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the worker and wait for it to exit before deleting files
|
||||||
|
const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777';
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${workerPort}/api/admin/shutdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
// Poll health endpoint until worker is gone (max 10s)
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${workerPort}/api/health`, {
|
||||||
|
signal: AbortSignal.timeout(1000),
|
||||||
|
});
|
||||||
|
// Still alive — keep waiting
|
||||||
|
} catch {
|
||||||
|
break; // Connection refused = worker is gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.log.info('Worker service stopped.');
|
||||||
|
} catch {
|
||||||
|
// Worker may not be running — that is fine
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.tasks([
|
||||||
|
{
|
||||||
|
title: 'Removing marketplace directory',
|
||||||
|
task: async () => {
|
||||||
|
const removed = removeMarketplaceDirectory();
|
||||||
|
return removed
|
||||||
|
? `Marketplace directory removed ${pc.green('OK')}`
|
||||||
|
: `Marketplace directory not found ${pc.dim('skipped')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing cache directory',
|
||||||
|
task: async () => {
|
||||||
|
const removed = removeCacheDirectory();
|
||||||
|
return removed
|
||||||
|
? `Cache directory removed ${pc.green('OK')}`
|
||||||
|
: `Cache directory not found ${pc.dim('skipped')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing marketplace registration',
|
||||||
|
task: async () => {
|
||||||
|
removeFromKnownMarketplaces();
|
||||||
|
return `Marketplace registration removed ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing plugin registration',
|
||||||
|
task: async () => {
|
||||||
|
removeFromInstalledPlugins();
|
||||||
|
return `Plugin registration removed ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Removing from Claude settings',
|
||||||
|
task: async () => {
|
||||||
|
removeFromClaudeSettings();
|
||||||
|
return `Claude settings updated ${pc.green('OK')}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Remove IDE-specific hooks and config (best-effort, each is independent)
|
||||||
|
const ideCleanups: Array<{ label: string; fn: () => Promise<number> | number }> = [
|
||||||
|
{ label: 'Gemini CLI hooks', fn: async () => {
|
||||||
|
const { uninstallGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||||
|
return uninstallGeminiCliHooks();
|
||||||
|
}},
|
||||||
|
{ label: 'Windsurf hooks', fn: async () => {
|
||||||
|
const { uninstallWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||||
|
return uninstallWindsurfHooks();
|
||||||
|
}},
|
||||||
|
{ label: 'OpenCode plugin', fn: async () => {
|
||||||
|
const { uninstallOpenCodePlugin } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||||
|
return uninstallOpenCodePlugin();
|
||||||
|
}},
|
||||||
|
{ label: 'OpenClaw plugin', fn: async () => {
|
||||||
|
const { uninstallOpenClawPlugin } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||||
|
return uninstallOpenClawPlugin();
|
||||||
|
}},
|
||||||
|
{ label: 'Codex CLI', fn: async () => {
|
||||||
|
const { uninstallCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||||
|
return uninstallCodexCli();
|
||||||
|
}},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { label, fn } of ideCleanups) {
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
if (result === 0) {
|
||||||
|
p.log.info(`${label}: removed.`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IDE not configured or uninstaller errored — skip silently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.note(
|
||||||
|
[
|
||||||
|
`Your data directory at ${pc.cyan('~/.claude-mem')} was preserved.`,
|
||||||
|
'To remove it manually: rm -rf ~/.claude-mem',
|
||||||
|
].join('\n'),
|
||||||
|
'Note',
|
||||||
|
);
|
||||||
|
|
||||||
|
p.outro(pc.green('claude-mem has been uninstalled.'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* NPX CLI entry point for claude-mem.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx claude-mem → interactive install
|
||||||
|
* npx claude-mem install → interactive install
|
||||||
|
* npx claude-mem install --ide <id> → direct IDE setup
|
||||||
|
* npx claude-mem update → update to latest version
|
||||||
|
* npx claude-mem uninstall → remove plugin and IDE configs
|
||||||
|
* npx claude-mem version → print version
|
||||||
|
* npx claude-mem start → start worker service
|
||||||
|
* npx claude-mem stop → stop worker service
|
||||||
|
* npx claude-mem restart → restart worker service
|
||||||
|
* npx claude-mem status → show worker status
|
||||||
|
* npx claude-mem search <query> → search observations
|
||||||
|
* npx claude-mem transcript watch → start transcript watcher
|
||||||
|
*
|
||||||
|
* This file is pure Node.js — Bun is NOT required for install commands.
|
||||||
|
* Runtime commands (`start`, `stop`, etc.) delegate to Bun via the installed plugin.
|
||||||
|
*/
|
||||||
|
import pc from 'picocolors';
|
||||||
|
import { readPluginVersion } from './utils/paths.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Argument parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0]?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Help text
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
const version = readPluginVersion();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
${pc.bold('claude-mem')} v${version} — persistent memory for AI coding assistants
|
||||||
|
|
||||||
|
${pc.bold('Install Commands')} (no Bun required):
|
||||||
|
${pc.cyan('npx claude-mem')} Interactive install
|
||||||
|
${pc.cyan('npx claude-mem install')} Interactive install
|
||||||
|
${pc.cyan('npx claude-mem install --ide <id>')} Install for specific IDE
|
||||||
|
${pc.cyan('npx claude-mem update')} Update to latest version
|
||||||
|
${pc.cyan('npx claude-mem uninstall')} Remove plugin and configs
|
||||||
|
${pc.cyan('npx claude-mem version')} Print version
|
||||||
|
|
||||||
|
${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin):
|
||||||
|
${pc.cyan('npx claude-mem start')} Start worker service
|
||||||
|
${pc.cyan('npx claude-mem stop')} Stop worker service
|
||||||
|
${pc.cyan('npx claude-mem restart')} Restart worker service
|
||||||
|
${pc.cyan('npx claude-mem status')} Show worker status
|
||||||
|
${pc.cyan('npx claude-mem search <query>')} Search observations
|
||||||
|
${pc.cyan('npx claude-mem transcript watch')} Start transcript watcher
|
||||||
|
|
||||||
|
${pc.bold('IDE Identifiers')}:
|
||||||
|
claude-code, cursor, gemini-cli, opencode, openclaw,
|
||||||
|
windsurf, codex-cli, copilot-cli, antigravity, goose,
|
||||||
|
crush, roo-code, warp
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
switch (command) {
|
||||||
|
// -- No command: default to install ------------------------------------
|
||||||
|
case '': {
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Install -----------------------------------------------------------
|
||||||
|
case 'install': {
|
||||||
|
const ideIndex = args.indexOf('--ide');
|
||||||
|
const ideValue = ideIndex !== -1 ? args[ideIndex + 1] : undefined;
|
||||||
|
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand({ ide: ideValue });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Update (alias for install — overwrite with latest) ----------------
|
||||||
|
case 'update':
|
||||||
|
case 'upgrade': {
|
||||||
|
const { runInstallCommand } = await import('./commands/install.js');
|
||||||
|
await runInstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Uninstall ---------------------------------------------------------
|
||||||
|
case 'uninstall':
|
||||||
|
case 'remove': {
|
||||||
|
const { runUninstallCommand } = await import('./commands/uninstall.js');
|
||||||
|
await runUninstallCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Version -----------------------------------------------------------
|
||||||
|
case 'version':
|
||||||
|
case '--version':
|
||||||
|
case '-v': {
|
||||||
|
console.log(readPluginVersion());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Help --------------------------------------------------------------
|
||||||
|
case 'help':
|
||||||
|
case '--help':
|
||||||
|
case '-h': {
|
||||||
|
printHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Runtime: start / stop / restart / status --------------------------
|
||||||
|
case 'start': {
|
||||||
|
const { runStartCommand } = await import('./commands/runtime.js');
|
||||||
|
runStartCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'stop': {
|
||||||
|
const { runStopCommand } = await import('./commands/runtime.js');
|
||||||
|
runStopCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'restart': {
|
||||||
|
const { runRestartCommand } = await import('./commands/runtime.js');
|
||||||
|
runRestartCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'status': {
|
||||||
|
const { runStatusCommand } = await import('./commands/runtime.js');
|
||||||
|
runStatusCommand();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Search ------------------------------------------------------------
|
||||||
|
case 'search': {
|
||||||
|
const { runSearchCommand } = await import('./commands/runtime.js');
|
||||||
|
await runSearchCommand(args.slice(1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Transcript --------------------------------------------------------
|
||||||
|
case 'transcript': {
|
||||||
|
const subCommand = args[1]?.toLowerCase();
|
||||||
|
if (subCommand === 'watch') {
|
||||||
|
const { runTranscriptWatchCommand } = await import('./commands/runtime.js');
|
||||||
|
runTranscriptWatchCommand();
|
||||||
|
} else {
|
||||||
|
console.error(pc.red(`Unknown transcript subcommand: ${subCommand ?? '(none)'}`));
|
||||||
|
console.error(`Usage: npx claude-mem transcript watch`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Unknown -----------------------------------------------------------
|
||||||
|
default: {
|
||||||
|
console.error(pc.red(`Unknown command: ${command}`));
|
||||||
|
console.error(`Run ${pc.bold('npx claude-mem --help')} for usage information.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(pc.red('Fatal error:'), error.message || error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Bun binary resolution utility.
|
||||||
|
*
|
||||||
|
* Extracted from `plugin/scripts/bun-runner.js` so that the NPX CLI
|
||||||
|
* can locate Bun without duplicating the search logic.
|
||||||
|
*
|
||||||
|
* Pure Node.js — no Bun APIs used.
|
||||||
|
*/
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { IS_WINDOWS } from './paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Well-known locations where Bun might be installed, beyond PATH.
|
||||||
|
* Order matches the search priority in bun-runner.js and smart-install.js.
|
||||||
|
*/
|
||||||
|
function bunCandidatePaths(): string[] {
|
||||||
|
if (IS_WINDOWS) {
|
||||||
|
return [
|
||||||
|
join(homedir(), '.bun', 'bin', 'bun.exe'),
|
||||||
|
join(process.env.USERPROFILE || homedir(), '.bun', 'bin', 'bun.exe'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
join(homedir(), '.bun', 'bin', 'bun'),
|
||||||
|
'/usr/local/bin/bun',
|
||||||
|
'/opt/homebrew/bin/bun',
|
||||||
|
'/home/linuxbrew/.linuxbrew/bin/bun',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to locate the Bun executable.
|
||||||
|
*
|
||||||
|
* 1. Check PATH via `which` / `where`.
|
||||||
|
* 2. Probe well-known installation directories.
|
||||||
|
*
|
||||||
|
* Returns the absolute path to the binary, `'bun'` if it is in PATH,
|
||||||
|
* or `null` if Bun cannot be found.
|
||||||
|
*/
|
||||||
|
export function resolveBunBinaryPath(): string | null {
|
||||||
|
// Try PATH first
|
||||||
|
const whichCommand = IS_WINDOWS ? 'where' : 'which';
|
||||||
|
const pathCheck = spawnSync(whichCommand, ['bun'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: IS_WINDOWS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pathCheck.status === 0 && pathCheck.stdout.trim()) {
|
||||||
|
return 'bun'; // Available in PATH — use short name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe known install locations
|
||||||
|
for (const candidatePath of bunCandidatePaths()) {
|
||||||
|
if (existsSync(candidatePath)) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the installed Bun version string (e.g. `"1.2.3"`), or `null`
|
||||||
|
* if Bun is not available.
|
||||||
|
*/
|
||||||
|
export function getBunVersionString(): string | null {
|
||||||
|
const bunPath = resolveBunBinaryPath();
|
||||||
|
if (!bunPath) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(bunPath, ['--version'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
shell: IS_WINDOWS,
|
||||||
|
});
|
||||||
|
return result.status === 0 ? result.stdout.trim() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Shared path utilities for the NPX CLI.
|
||||||
|
*
|
||||||
|
* All platform-specific path logic is centralized here so that every command
|
||||||
|
* resolves directories in exactly the same way, regardless of OS.
|
||||||
|
*/
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Platform detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const IS_WINDOWS = process.platform === 'win32';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Root of the Claude Code config directory. */
|
||||||
|
export function claudeConfigDirectory(): string {
|
||||||
|
return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marketplace install directory for thedotmack. */
|
||||||
|
export function marketplaceDirectory(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'plugins', 'marketplaces', 'thedotmack');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level plugins directory. */
|
||||||
|
export function pluginsDirectory(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `known_marketplaces.json`. */
|
||||||
|
export function knownMarketplacesPath(): string {
|
||||||
|
return join(pluginsDirectory(), 'known_marketplaces.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `installed_plugins.json`. */
|
||||||
|
export function installedPluginsPath(): string {
|
||||||
|
return join(pluginsDirectory(), 'installed_plugins.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path to `~/.claude/settings.json`. */
|
||||||
|
export function claudeSettingsPath(): string {
|
||||||
|
return join(claudeConfigDirectory(), 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugin cache directory for a specific version. */
|
||||||
|
export function pluginCacheDirectory(version: string): string {
|
||||||
|
return join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem', version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** claude-mem data directory (default `~/.claude-mem`). */
|
||||||
|
export function claudeMemDataDirectory(): string {
|
||||||
|
return join(homedir(), '.claude-mem');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NPM package root (where the NPX package lives on disk)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the root of the installed npm package.
|
||||||
|
*
|
||||||
|
* After bundling, the CLI entry point lives at `<pkg>/dist/npx-cli/index.js`.
|
||||||
|
* Walking up 2 levels from `import.meta.url` reaches the package root
|
||||||
|
* where `plugin/` and `package.json` can be found.
|
||||||
|
*/
|
||||||
|
export function npmPackageRootDirectory(): string {
|
||||||
|
const currentFilePath = fileURLToPath(import.meta.url);
|
||||||
|
// <pkg>/dist/npx-cli/index.js -> up 2 levels -> <pkg>
|
||||||
|
const root = join(dirname(currentFilePath), '..', '..');
|
||||||
|
if (!existsSync(join(root, 'package.json'))) {
|
||||||
|
throw new Error(
|
||||||
|
`npmPackageRootDirectory: expected package.json at ${root}. ` +
|
||||||
|
`Bundle structure may have changed — update the path walk.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the `plugin/` directory bundled inside the npm package.
|
||||||
|
*/
|
||||||
|
export function npmPackagePluginDirectory(): string {
|
||||||
|
return join(npmPackageRootDirectory(), 'plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current plugin version from the npm package's
|
||||||
|
* `plugin/.claude-plugin/plugin.json` (preferred) or from `package.json`.
|
||||||
|
*/
|
||||||
|
export function readPluginVersion(): string {
|
||||||
|
// Try plugin.json first (authoritative for plugin version)
|
||||||
|
const pluginJsonPath = join(npmPackagePluginDirectory(), '.claude-plugin', 'plugin.json');
|
||||||
|
if (existsSync(pluginJsonPath)) {
|
||||||
|
try {
|
||||||
|
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||||
|
if (pluginJson.version) return pluginJson.version;
|
||||||
|
} catch {
|
||||||
|
// Fall through to package.json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to package.json at package root
|
||||||
|
const packageJsonPath = join(npmPackageRootDirectory(), 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
if (packageJson.version) return packageJson.version;
|
||||||
|
} catch {
|
||||||
|
// Unable to read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Installation detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns true if the plugin appears to be installed in the marketplace dir. */
|
||||||
|
export function isPluginInstalled(): boolean {
|
||||||
|
const marketplaceDir = marketplaceDirectory();
|
||||||
|
return existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// JSON file helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ensureDirectoryExists(directoryPath: string): void {
|
||||||
|
if (!existsSync(directoryPath)) {
|
||||||
|
mkdirSync(directoryPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
|
||||||
|
* Kept as re-export for backward compatibility.
|
||||||
|
*/
|
||||||
|
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
|
||||||
|
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||||
|
ensureDirectoryExists(dirname(filepath));
|
||||||
|
writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* CodexCliInstaller - Codex CLI integration for claude-mem
|
||||||
|
*
|
||||||
|
* Uses transcript-only watching (no notify hook). The watcher infrastructure
|
||||||
|
* already exists in src/services/transcripts/. This installer:
|
||||||
|
*
|
||||||
|
* 1. Writes/merges transcript-watch config to ~/.claude-mem/transcript-watch.json
|
||||||
|
* 2. Sets up watch for ~/.codex/sessions/**\/*.jsonl using existing watcher
|
||||||
|
* 3. Injects context via ~/.codex/AGENTS.md (Codex reads this natively)
|
||||||
|
*
|
||||||
|
* Anti-patterns:
|
||||||
|
* - Does NOT add notify hooks -- transcript watching is sufficient
|
||||||
|
* - Does NOT modify existing transcript watcher infrastructure
|
||||||
|
* - Does NOT overwrite existing transcript-watch.json -- merges only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIG_PATH,
|
||||||
|
DEFAULT_STATE_PATH,
|
||||||
|
SAMPLE_CONFIG,
|
||||||
|
} from '../transcripts/config.js';
|
||||||
|
import type { TranscriptWatchConfig, WatchTarget } from '../transcripts/types.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CODEX_DIR = path.join(homedir(), '.codex');
|
||||||
|
const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md');
|
||||||
|
const CLAUDE_MEM_DIR = path.join(homedir(), '.claude-mem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watch name used to identify the Codex CLI entry in transcript-watch.json.
|
||||||
|
* Must match the name in SAMPLE_CONFIG for merging to work correctly.
|
||||||
|
*/
|
||||||
|
const CODEX_WATCH_NAME = 'codex';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transcript Watch Config Merging
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load existing transcript-watch.json, or return an empty config scaffold.
|
||||||
|
* Never throws -- returns a valid empty config on any parse error.
|
||||||
|
*/
|
||||||
|
function loadExistingTranscriptWatchConfig(): TranscriptWatchConfig {
|
||||||
|
const configPath = DEFAULT_CONFIG_PATH;
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||||
|
|
||||||
|
// Ensure required fields exist
|
||||||
|
if (!parsed.version) parsed.version = 1;
|
||||||
|
if (!parsed.watches) parsed.watches = [];
|
||||||
|
if (!parsed.schemas) parsed.schemas = {};
|
||||||
|
if (!parsed.stateFile) parsed.stateFile = DEFAULT_STATE_PATH;
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.error('CODEX', 'Corrupt transcript-watch.json, creating backup', { path: configPath }, parseError as Error);
|
||||||
|
|
||||||
|
// Back up corrupt file
|
||||||
|
const backupPath = `${configPath}.backup.${Date.now()}`;
|
||||||
|
writeFileSync(backupPath, readFileSync(configPath));
|
||||||
|
console.warn(` Backed up corrupt transcript-watch.json to ${backupPath}`);
|
||||||
|
|
||||||
|
return { version: 1, schemas: {}, watches: [], stateFile: DEFAULT_STATE_PATH };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Codex watch configuration into existing transcript-watch.json.
|
||||||
|
*
|
||||||
|
* - If a watch with name 'codex' already exists, it is replaced in-place.
|
||||||
|
* - If the 'codex' schema already exists, it is replaced in-place.
|
||||||
|
* - All other watches and schemas are preserved untouched.
|
||||||
|
*/
|
||||||
|
function mergeCodexWatchConfig(existingConfig: TranscriptWatchConfig): TranscriptWatchConfig {
|
||||||
|
const merged = { ...existingConfig };
|
||||||
|
|
||||||
|
// Merge schemas: add/replace the codex schema
|
||||||
|
merged.schemas = { ...merged.schemas };
|
||||||
|
const codexSchema = SAMPLE_CONFIG.schemas?.[CODEX_WATCH_NAME];
|
||||||
|
if (codexSchema) {
|
||||||
|
merged.schemas[CODEX_WATCH_NAME] = codexSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge watches: add/replace the codex watch entry
|
||||||
|
const codexWatchFromSample = SAMPLE_CONFIG.watches.find(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codexWatchFromSample) {
|
||||||
|
const existingWatchIndex = merged.watches.findIndex(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingWatchIndex !== -1) {
|
||||||
|
// Replace existing codex watch in-place
|
||||||
|
merged.watches[existingWatchIndex] = codexWatchFromSample;
|
||||||
|
} else {
|
||||||
|
// Append new codex watch
|
||||||
|
merged.watches.push(codexWatchFromSample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the merged transcript-watch.json config atomically.
|
||||||
|
*/
|
||||||
|
function writeTranscriptWatchConfig(config: TranscriptWatchConfig): void {
|
||||||
|
mkdirSync(CLAUDE_MEM_DIR, { recursive: true });
|
||||||
|
writeFileSync(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Context Injection (AGENTS.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject claude-mem context section into ~/.codex/AGENTS.md.
|
||||||
|
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md and GEMINI.md.
|
||||||
|
* Preserves any existing user content outside the tags.
|
||||||
|
*/
|
||||||
|
function injectCodexAgentsMdContext(): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(CODEX_DIR, { recursive: true });
|
||||||
|
|
||||||
|
let existingContent = '';
|
||||||
|
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||||
|
existingContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial placeholder content -- will be populated after first session
|
||||||
|
const contextContent = [
|
||||||
|
'# Recent Activity',
|
||||||
|
'',
|
||||||
|
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
|
||||||
|
'',
|
||||||
|
'*No context yet. Complete your first session and context will appear here.*',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const finalContent = replaceTaggedContent(existingContent, contextContent);
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent);
|
||||||
|
console.log(` Injected context placeholder into ${CODEX_AGENTS_MD_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Non-fatal -- transcript watching still works without context injection
|
||||||
|
logger.warn('CODEX', 'Failed to inject AGENTS.md context', { error: (error as Error).message });
|
||||||
|
console.warn(` Warning: Could not inject context into AGENTS.md: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claude-mem context section from AGENTS.md.
|
||||||
|
* Preserves user content outside the <claude-mem-context> tags.
|
||||||
|
*/
|
||||||
|
function removeCodexAgentsMdContext(): void {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CODEX_AGENTS_MD_PATH)) return;
|
||||||
|
|
||||||
|
const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
const startTag = '<claude-mem-context>';
|
||||||
|
const endTag = '</claude-mem-context>';
|
||||||
|
|
||||||
|
const startIdx = content.indexOf(startTag);
|
||||||
|
const endIdx = content.indexOf(endTag);
|
||||||
|
|
||||||
|
if (startIdx === -1 || endIdx === -1) return;
|
||||||
|
|
||||||
|
// Remove the tagged section and any surrounding blank lines
|
||||||
|
const before = content.substring(0, startIdx).replace(/\n+$/, '');
|
||||||
|
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
|
||||||
|
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
|
||||||
|
|
||||||
|
if (finalContent) {
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, finalContent + '\n');
|
||||||
|
} else {
|
||||||
|
// File would be empty -- leave it empty rather than deleting
|
||||||
|
// (user may have other tooling that expects it to exist)
|
||||||
|
writeFileSync(CODEX_AGENTS_MD_PATH, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Removed context section from ${CODEX_AGENTS_MD_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('CODEX', 'Failed to clean AGENTS.md context', { error: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Install
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Codex CLI integration for claude-mem.
|
||||||
|
*
|
||||||
|
* 1. Merges Codex transcript-watch config into ~/.claude-mem/transcript-watch.json
|
||||||
|
* 2. Injects context placeholder into ~/.codex/AGENTS.md
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installCodexCli(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for Codex CLI (transcript watching)...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Merge transcript-watch config
|
||||||
|
const existingConfig = loadExistingTranscriptWatchConfig();
|
||||||
|
const mergedConfig = mergeCodexWatchConfig(existingConfig);
|
||||||
|
writeTranscriptWatchConfig(mergedConfig);
|
||||||
|
console.log(` Updated ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log(` Watch path: ~/.codex/sessions/**/*.jsonl`);
|
||||||
|
console.log(` Schema: codex (v${SAMPLE_CONFIG.schemas?.codex?.version ?? '?'})`);
|
||||||
|
|
||||||
|
// Step 2: Inject context into AGENTS.md
|
||||||
|
injectCodexAgentsMdContext();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Transcript watch config: ${DEFAULT_CONFIG_PATH}
|
||||||
|
Context file: ${CODEX_AGENTS_MD_PATH}
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
- claude-mem watches Codex session JSONL files for new activity
|
||||||
|
- No hooks needed -- transcript watching is fully automatic
|
||||||
|
- Context from past sessions is injected via ${CODEX_AGENTS_MD_PATH}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Use Codex CLI as usual -- memory capture is automatic!
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Uninstall
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove Codex CLI integration from claude-mem.
|
||||||
|
*
|
||||||
|
* 1. Removes the codex watch and schema from transcript-watch.json (preserves others)
|
||||||
|
* 2. Removes context section from AGENTS.md (preserves user content)
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallCodexCli(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Codex CLI integration...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Remove codex watch from transcript-watch.json
|
||||||
|
if (existsSync(DEFAULT_CONFIG_PATH)) {
|
||||||
|
const config = loadExistingTranscriptWatchConfig();
|
||||||
|
|
||||||
|
// Remove codex watch
|
||||||
|
config.watches = config.watches.filter(
|
||||||
|
(w: WatchTarget) => w.name !== CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove codex schema
|
||||||
|
if (config.schemas) {
|
||||||
|
delete config.schemas[CODEX_WATCH_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTranscriptWatchConfig(config);
|
||||||
|
console.log(` Removed codex watch from ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
} else {
|
||||||
|
console.log(' No transcript-watch.json found -- nothing to remove.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Remove context section from AGENTS.md
|
||||||
|
removeCodexAgentsMdContext();
|
||||||
|
|
||||||
|
console.log('\nUninstallation complete!');
|
||||||
|
console.log('Restart claude-mem worker to apply changes.\n');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API: Status Check
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Codex CLI integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational)
|
||||||
|
*/
|
||||||
|
export function checkCodexCliStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Codex CLI Integration Status\n');
|
||||||
|
|
||||||
|
// Check transcript-watch.json
|
||||||
|
if (!existsSync(DEFAULT_CONFIG_PATH)) {
|
||||||
|
console.log('Status: Not installed');
|
||||||
|
console.log(` No transcript watch config at ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadExistingTranscriptWatchConfig();
|
||||||
|
const codexWatch = config.watches.find(
|
||||||
|
(w: WatchTarget) => w.name === CODEX_WATCH_NAME,
|
||||||
|
);
|
||||||
|
const codexSchema = config.schemas?.[CODEX_WATCH_NAME];
|
||||||
|
|
||||||
|
if (!codexWatch) {
|
||||||
|
console.log('Status: Not installed');
|
||||||
|
console.log(' transcript-watch.json exists but no codex watch configured.');
|
||||||
|
console.log('\nRun: npx claude-mem install --ide codex-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Status: Installed');
|
||||||
|
console.log(` Config: ${DEFAULT_CONFIG_PATH}`);
|
||||||
|
console.log(` Watch path: ${codexWatch.path}`);
|
||||||
|
console.log(` Schema: ${codexSchema ? `codex (v${codexSchema.version ?? '?'})` : 'missing'}`);
|
||||||
|
console.log(` Start at end: ${codexWatch.startAtEnd ?? false}`);
|
||||||
|
|
||||||
|
// Check context config
|
||||||
|
if (codexWatch.context) {
|
||||||
|
console.log(` Context mode: ${codexWatch.context.mode}`);
|
||||||
|
console.log(` Context path: ${codexWatch.context.path ?? 'default'}`);
|
||||||
|
console.log(` Context updates on: ${codexWatch.context.updateOn?.join(', ') ?? 'none'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check AGENTS.md
|
||||||
|
if (existsSync(CODEX_AGENTS_MD_PATH)) {
|
||||||
|
const mdContent = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8');
|
||||||
|
if (mdContent.includes('<claude-mem-context>')) {
|
||||||
|
console.log(` Context: Active (${CODEX_AGENTS_MD_PATH})`);
|
||||||
|
} else {
|
||||||
|
console.log(` Context: AGENTS.md exists but no context tags`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Context: No AGENTS.md file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ~/.codex/sessions exists (indicates Codex has been used)
|
||||||
|
const sessionsDir = path.join(CODEX_DIR, 'sessions');
|
||||||
|
if (existsSync(sessionsDir)) {
|
||||||
|
console.log(` Sessions directory: exists`);
|
||||||
|
} else {
|
||||||
|
console.log(` Sessions directory: not yet created (use Codex CLI to generate sessions)`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('Status: Unknown');
|
||||||
|
console.log(' Could not parse transcript-watch.json.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -133,9 +133,7 @@ export function findMcpServerPath(): string | null {
|
|||||||
const possiblePaths = [
|
const possiblePaths = [
|
||||||
// Marketplace install location
|
// Marketplace install location
|
||||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
|
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
// Development/source location
|
||||||
path.join(path.dirname(__filename), 'mcp-server.cjs'),
|
|
||||||
// Alternative dev location
|
|
||||||
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
|
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -155,9 +153,7 @@ export function findWorkerServicePath(): string | null {
|
|||||||
const possiblePaths = [
|
const possiblePaths = [
|
||||||
// Marketplace install location
|
// Marketplace install location
|
||||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
||||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
// Development/source location
|
||||||
path.join(path.dirname(__filename), 'worker-service.cjs'),
|
|
||||||
// Alternative dev location
|
|
||||||
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
|
path.join(process.cwd(), 'plugin', 'scripts', 'worker-service.cjs'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
/**
|
||||||
|
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
|
||||||
|
*
|
||||||
|
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
|
||||||
|
* bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
*
|
||||||
|
* This routes through the hook-command.ts framework:
|
||||||
|
* readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker
|
||||||
|
*
|
||||||
|
* Gemini CLI supports 11 lifecycle hooks; we register 8 that map to
|
||||||
|
* useful memory events. See src/cli/adapters/gemini-cli.ts for the
|
||||||
|
* adapter that normalizes Gemini's stdin JSON to NormalizedHookInput.
|
||||||
|
*
|
||||||
|
* Hook config format (verified against Gemini CLI source):
|
||||||
|
* {
|
||||||
|
* "hooks": {
|
||||||
|
* "AfterTool": [{
|
||||||
|
* "matcher": "*",
|
||||||
|
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }]
|
||||||
|
* }]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** A single hook entry in a Gemini CLI hook group */
|
||||||
|
interface GeminiHookEntry {
|
||||||
|
name: string;
|
||||||
|
type: 'command';
|
||||||
|
command: string;
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hook group — matcher selects which tools/events this applies to */
|
||||||
|
interface GeminiHookGroup {
|
||||||
|
matcher: string;
|
||||||
|
hooks: GeminiHookEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hooks section in ~/.gemini/settings.json */
|
||||||
|
interface GeminiHooksConfig {
|
||||||
|
[eventName: string]: GeminiHookGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */
|
||||||
|
interface GeminiSettingsJson {
|
||||||
|
hooks?: GeminiHooksConfig;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const GEMINI_CONFIG_DIR = path.join(homedir(), '.gemini');
|
||||||
|
const GEMINI_SETTINGS_PATH = path.join(GEMINI_CONFIG_DIR, 'settings.json');
|
||||||
|
const GEMINI_MD_PATH = path.join(GEMINI_CONFIG_DIR, 'GEMINI.md');
|
||||||
|
|
||||||
|
const HOOK_NAME = 'claude-mem';
|
||||||
|
const HOOK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping from Gemini CLI hook events to internal claude-mem event types.
|
||||||
|
*
|
||||||
|
* These events are processed by hookCommand() in src/cli/hook-command.ts,
|
||||||
|
* which reads stdin via readJsonFromStdin(), normalizes through the
|
||||||
|
* gemini-cli adapter, and dispatches to the matching event handler.
|
||||||
|
*
|
||||||
|
* Events NOT mapped (too chatty for memory capture):
|
||||||
|
* BeforeModel, AfterModel, BeforeToolSelection
|
||||||
|
*/
|
||||||
|
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
|
||||||
|
'SessionStart': 'context',
|
||||||
|
'BeforeAgent': 'user-message',
|
||||||
|
'AfterAgent': 'observation',
|
||||||
|
'BeforeTool': 'observation',
|
||||||
|
'AfterTool': 'observation',
|
||||||
|
'PreCompress': 'summarize',
|
||||||
|
'Notification': 'observation',
|
||||||
|
'SessionEnd': 'session-complete',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook Command Builder
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the hook command string for a given Gemini CLI event.
|
||||||
|
*
|
||||||
|
* The command invokes worker-service.cjs with the `hook` subcommand,
|
||||||
|
* which delegates to hookCommand('gemini-cli', event) — the same
|
||||||
|
* framework used by Claude Code and Cursor hooks.
|
||||||
|
*
|
||||||
|
* Pipeline: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
* → worker-service.ts parses args, ensures worker daemon is running
|
||||||
|
* → hookCommand('gemini-cli', '<event>')
|
||||||
|
* → readJsonFromStdin() reads Gemini's JSON payload
|
||||||
|
* → geminiCliAdapter.normalizeInput() → NormalizedHookInput
|
||||||
|
* → eventHandler.execute(input)
|
||||||
|
* → geminiCliAdapter.formatOutput(result)
|
||||||
|
* → JSON.stringify to stdout
|
||||||
|
*/
|
||||||
|
function buildHookCommand(
|
||||||
|
bunPath: string,
|
||||||
|
workerServicePath: string,
|
||||||
|
geminiEventName: string,
|
||||||
|
): string {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[geminiEventName];
|
||||||
|
if (!internalEvent) {
|
||||||
|
throw new Error(`Unknown Gemini CLI event: ${geminiEventName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-escape backslashes intentionally: this command string is embedded inside
|
||||||
|
// a JSON value, so `\\` in the source becomes `\` when the JSON is parsed by the
|
||||||
|
// IDE. Without double-escaping, Windows paths like C:\Users would lose their
|
||||||
|
// backslashes and break when the IDE deserializes the hook configuration.
|
||||||
|
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
|
||||||
|
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
|
||||||
|
|
||||||
|
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a hook group entry for a Gemini CLI event.
|
||||||
|
* Uses matcher "*" to match all tools/contexts for that event.
|
||||||
|
*/
|
||||||
|
function createHookGroup(hookCommand: string): GeminiHookGroup {
|
||||||
|
return {
|
||||||
|
matcher: '*',
|
||||||
|
hooks: [{
|
||||||
|
name: HOOK_NAME,
|
||||||
|
type: 'command',
|
||||||
|
command: hookCommand,
|
||||||
|
timeout: HOOK_TIMEOUT_MS,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings JSON Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ~/.gemini/settings.json, returning empty object if missing.
|
||||||
|
* Throws on corrupt JSON to prevent silent data loss.
|
||||||
|
*/
|
||||||
|
function readGeminiSettings(): GeminiSettingsJson {
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8');
|
||||||
|
try {
|
||||||
|
return JSON.parse(content) as GeminiSettingsJson;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt JSON in ${GEMINI_SETTINGS_PATH}, refusing to overwrite user settings`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write settings back to ~/.gemini/settings.json.
|
||||||
|
* Creates the directory if it doesn't exist.
|
||||||
|
*/
|
||||||
|
function writeGeminiSettings(settings: GeminiSettingsJson): void {
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merge claude-mem hooks into existing settings.
|
||||||
|
*
|
||||||
|
* For each event:
|
||||||
|
* - If the event already has a hook group with a claude-mem hook, update it
|
||||||
|
* - Otherwise, append a new hook group
|
||||||
|
*
|
||||||
|
* Preserves all non-claude-mem hooks and all non-hook settings.
|
||||||
|
*/
|
||||||
|
function mergeHooksIntoSettings(
|
||||||
|
existingSettings: GeminiSettingsJson,
|
||||||
|
newHooks: GeminiHooksConfig,
|
||||||
|
): GeminiSettingsJson {
|
||||||
|
const settings = { ...existingSettings };
|
||||||
|
if (!settings.hooks) {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [eventName, newGroups] of Object.entries(newHooks)) {
|
||||||
|
const existingGroups: GeminiHookGroup[] = settings.hooks[eventName] ?? [];
|
||||||
|
|
||||||
|
// For each new hook group, check if there's already a group
|
||||||
|
// containing a claude-mem hook — update it in place
|
||||||
|
for (const newGroup of newGroups) {
|
||||||
|
const existingGroupIndex = existingGroups.findIndex((group: GeminiHookGroup) =>
|
||||||
|
group.hooks.some((hook: GeminiHookEntry) => hook.name === HOOK_NAME)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingGroupIndex >= 0) {
|
||||||
|
// Update existing group: replace the claude-mem hook entry
|
||||||
|
const existingGroup: GeminiHookGroup = existingGroups[existingGroupIndex];
|
||||||
|
const hookIndex = existingGroup.hooks.findIndex((hook: GeminiHookEntry) => hook.name === HOOK_NAME);
|
||||||
|
if (hookIndex >= 0) {
|
||||||
|
existingGroup.hooks[hookIndex] = newGroup.hooks[0];
|
||||||
|
} else {
|
||||||
|
existingGroup.hooks.push(newGroup.hooks[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing claude-mem group — append
|
||||||
|
existingGroups.push(newGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.hooks[eventName] = existingGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GEMINI.md Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append or update the claude-mem context section in ~/.gemini/GEMINI.md.
|
||||||
|
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
|
||||||
|
*/
|
||||||
|
function setupGeminiMdContextSection(): void {
|
||||||
|
const contextTag = '<claude-mem-context>';
|
||||||
|
const contextEndTag = '</claude-mem-context>';
|
||||||
|
const placeholder = `${contextTag}
|
||||||
|
# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
${contextEndTag}`;
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
content = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes(contextTag)) {
|
||||||
|
// Already has claude-mem section — leave it alone (may have real context)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the section
|
||||||
|
const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : '';
|
||||||
|
const newContent = content + separator + placeholder + '\n';
|
||||||
|
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_MD_PATH, newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install claude-mem hooks into ~/.gemini/settings.json.
|
||||||
|
*
|
||||||
|
* Merges hooks non-destructively: existing settings and non-claude-mem
|
||||||
|
* hooks are preserved. Existing claude-mem hooks are updated in place.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installGeminiCliHooks(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem Gemini CLI hooks...\n');
|
||||||
|
|
||||||
|
// Find required paths
|
||||||
|
const workerServicePath = findWorkerServicePath();
|
||||||
|
if (!workerServicePath) {
|
||||||
|
console.error('Could not find worker-service.cjs');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunPath = findBunPath();
|
||||||
|
console.log(` Using Bun runtime: ${bunPath}`);
|
||||||
|
console.log(` Worker service: ${workerServicePath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build hook commands for all mapped events
|
||||||
|
const hooksConfig: GeminiHooksConfig = {};
|
||||||
|
for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) {
|
||||||
|
const command = buildHookCommand(bunPath, workerServicePath, geminiEvent);
|
||||||
|
hooksConfig[geminiEvent] = [createHookGroup(command)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing settings and merge
|
||||||
|
const existingSettings = readGeminiSettings();
|
||||||
|
const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig);
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
writeGeminiSettings(mergedSettings);
|
||||||
|
console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
|
// Setup GEMINI.md context injection
|
||||||
|
setupGeminiMdContextSection();
|
||||||
|
console.log(` Setup context injection in ${GEMINI_MD_PATH}`);
|
||||||
|
|
||||||
|
// List installed events
|
||||||
|
const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT);
|
||||||
|
console.log(` Registered ${eventNames.length} hook events:`);
|
||||||
|
for (const event of eventNames) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event];
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Hooks installed to: ${GEMINI_SETTINGS_PATH}
|
||||||
|
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: claude-mem start
|
||||||
|
2. Restart Gemini CLI to load the hooks
|
||||||
|
3. Memory will be captured automatically during sessions
|
||||||
|
|
||||||
|
Context Injection:
|
||||||
|
Context from past sessions is injected via ~/.gemini/GEMINI.md
|
||||||
|
and automatically included in Gemini CLI conversations.
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall claude-mem hooks from ~/.gemini/settings.json.
|
||||||
|
*
|
||||||
|
* Removes only claude-mem hooks — other hooks and settings are preserved.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallGeminiCliHooks(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Gemini CLI hooks...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
console.log(' No Gemini CLI settings found — nothing to uninstall.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = readGeminiSettings();
|
||||||
|
if (!settings.hooks) {
|
||||||
|
console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
// Remove claude-mem hooks from within each group, preserving other hooks
|
||||||
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
|
const filteredGroups = groups
|
||||||
|
.map(group => {
|
||||||
|
const remainingHooks = group.hooks.filter(hook => hook.name !== HOOK_NAME);
|
||||||
|
removedCount += group.hooks.length - remainingHooks.length;
|
||||||
|
return { ...group, hooks: remainingHooks };
|
||||||
|
})
|
||||||
|
.filter(group => group.hooks.length > 0);
|
||||||
|
|
||||||
|
if (filteredGroups.length > 0) {
|
||||||
|
settings.hooks[eventName] = filteredGroups;
|
||||||
|
} else {
|
||||||
|
delete settings.hooks[eventName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty hooks object
|
||||||
|
if (Object.keys(settings.hooks).length === 0) {
|
||||||
|
delete settings.hooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeGeminiSettings(settings);
|
||||||
|
console.log(` Removed ${removedCount} claude-mem hook(s) from ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
|
// Remove claude-mem context section from GEMINI.md
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
const contextRegex = /\n?<claude-mem-context>[\s\S]*?<\/claude-mem-context>\n?/;
|
||||||
|
if (contextRegex.test(mdContent)) {
|
||||||
|
mdContent = mdContent.replace(contextRegex, '');
|
||||||
|
writeFileSync(GEMINI_MD_PATH, mdContent);
|
||||||
|
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nUninstallation complete!\n');
|
||||||
|
console.log('Restart Gemini CLI to apply changes.');
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Gemini CLI hooks installation status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational)
|
||||||
|
*/
|
||||||
|
export function checkGeminiCliHooksStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
|
||||||
|
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
console.log('Gemini CLI settings: Not found');
|
||||||
|
console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`);
|
||||||
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: GeminiSettingsJson;
|
||||||
|
try {
|
||||||
|
settings = readGeminiSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Gemini CLI settings: ${(error as Error).message}\n`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.hooks) {
|
||||||
|
console.log('Gemini CLI settings: Found, but no hooks configured\n');
|
||||||
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for claude-mem hooks
|
||||||
|
const installedEvents: string[] = [];
|
||||||
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
|
const hasClaudeMem = groups.some(group =>
|
||||||
|
group.hooks.some(hook => hook.name === HOOK_NAME)
|
||||||
|
);
|
||||||
|
if (hasClaudeMem) {
|
||||||
|
installedEvents.push(eventName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installedEvents.length === 0) {
|
||||||
|
console.log('Gemini CLI settings: Found, but no claude-mem hooks\n');
|
||||||
|
console.log('Run: claude-mem install --ide gemini-cli\n');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Settings: ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`);
|
||||||
|
console.log(`Events: ${installedEvents.length} of ${Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT).length} mapped`);
|
||||||
|
for (const event of installedEvents) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event] ?? 'unknown';
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check GEMINI.md context
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
if (mdContent.includes('<claude-mem-context>')) {
|
||||||
|
console.log(`Context: Active (${GEMINI_MD_PATH})`);
|
||||||
|
} else {
|
||||||
|
console.log('Context: GEMINI.md exists but missing claude-mem section');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Context: No GEMINI.md found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle gemini-cli subcommand for hooks management.
|
||||||
|
*/
|
||||||
|
export async function handleGeminiCliCommand(subcommand: string, _args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'install':
|
||||||
|
return installGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'uninstall':
|
||||||
|
return uninstallGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return checkGeminiCliHooksStatus();
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
Claude-Mem Gemini CLI Integration
|
||||||
|
|
||||||
|
Usage: claude-mem gemini-cli <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install hooks into ~/.gemini/settings.json
|
||||||
|
uninstall Remove claude-mem hooks (preserves other hooks)
|
||||||
|
status Check installation status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
claude-mem gemini-cli install # Install hooks
|
||||||
|
claude-mem gemini-cli status # Check if installed
|
||||||
|
claude-mem gemini-cli uninstall # Remove hooks
|
||||||
|
|
||||||
|
For more info: https://docs.claude-mem.ai/usage/gemini-provider
|
||||||
|
`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
/**
|
||||||
|
* McpIntegrations - MCP-based IDE integrations for claude-mem
|
||||||
|
*
|
||||||
|
* Handles MCP config writing and context injection for IDEs that support
|
||||||
|
* the Model Context Protocol. These are "MCP-only" integrations: they provide
|
||||||
|
* search tools and context injection but do NOT capture transcripts.
|
||||||
|
*
|
||||||
|
* Supported IDEs:
|
||||||
|
* - Copilot CLI
|
||||||
|
* - Antigravity (Gemini)
|
||||||
|
* - Goose
|
||||||
|
* - Crush
|
||||||
|
* - Roo Code
|
||||||
|
* - Warp
|
||||||
|
*
|
||||||
|
* All IDEs point to the same MCP server: plugin/scripts/mcp-server.cjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { findMcpServerPath } from './CursorHooksInstaller.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem's MCP search tools for manual memory queries.`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the standard MCP server entry that all IDEs use.
|
||||||
|
* Points to the same mcp-server.cjs script.
|
||||||
|
*/
|
||||||
|
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpServerPath],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a standard MCP JSON config file, merging with existing config.
|
||||||
|
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
|
||||||
|
*/
|
||||||
|
function writeMcpJsonConfig(
|
||||||
|
configFilePath: string,
|
||||||
|
mcpServerPath: string,
|
||||||
|
serversKeyName: string = 'mcpServers',
|
||||||
|
): void {
|
||||||
|
const parentDirectory = path.dirname(configFilePath);
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
|
||||||
|
|
||||||
|
if (!existingConfig[serversKeyName]) {
|
||||||
|
existingConfig[serversKeyName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
|
||||||
|
|
||||||
|
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MCP Installer Factory (Phase 1D)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a JSON-based MCP IDE integration.
|
||||||
|
*/
|
||||||
|
interface McpInstallerConfig {
|
||||||
|
ideId: string;
|
||||||
|
ideLabel: string;
|
||||||
|
configPath: string;
|
||||||
|
configKey: 'servers' | 'mcpServers';
|
||||||
|
contextFile?: {
|
||||||
|
path: string;
|
||||||
|
isWorkspaceRelative: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function that creates an MCP installer for any JSON-config-based IDE.
|
||||||
|
* Handles MCP config writing and optional context injection.
|
||||||
|
*/
|
||||||
|
function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
|
||||||
|
return async (): Promise<number> => {
|
||||||
|
console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write MCP config
|
||||||
|
const configPath = config.configPath;
|
||||||
|
|
||||||
|
// Warp special case: skip config write if ~/.warp/ doesn't exist
|
||||||
|
if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
|
||||||
|
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
|
||||||
|
} else {
|
||||||
|
writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
|
||||||
|
console.log(` MCP config written to: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject context if configured
|
||||||
|
let contextPath: string | undefined;
|
||||||
|
if (config.contextFile) {
|
||||||
|
contextPath = config.contextFile.path;
|
||||||
|
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
||||||
|
console.log(` Context placeholder written to: ${contextPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
const summaryLines = [`\nInstallation complete!\n`];
|
||||||
|
summaryLines.push(`MCP config: ${configPath}`);
|
||||||
|
if (contextPath) {
|
||||||
|
summaryLines.push(`Context: ${contextPath}`);
|
||||||
|
}
|
||||||
|
summaryLines.push('');
|
||||||
|
summaryLines.push(`Note: This is an MCP-only integration providing search tools and context.`);
|
||||||
|
summaryLines.push(`Transcript capture is not available for ${config.ideLabel}.`);
|
||||||
|
if (config.ideId === 'warp') {
|
||||||
|
summaryLines.push('If MCP config via file is not supported, configure MCP through Warp Drive UI.');
|
||||||
|
}
|
||||||
|
summaryLines.push('');
|
||||||
|
summaryLines.push('Next steps:');
|
||||||
|
summaryLines.push(' 1. Start claude-mem worker: npx claude-mem start');
|
||||||
|
summaryLines.push(` 2. Restart ${config.ideLabel} to pick up the MCP server`);
|
||||||
|
summaryLines.push('');
|
||||||
|
console.log(summaryLines.join('\n'));
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Factory Configs for JSON-based IDEs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const COPILOT_CLI_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'copilot-cli',
|
||||||
|
ideLabel: 'Copilot CLI',
|
||||||
|
configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
|
||||||
|
configKey: 'servers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'antigravity',
|
||||||
|
ideLabel: 'Antigravity',
|
||||||
|
configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRUSH_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'crush',
|
||||||
|
ideLabel: 'Crush',
|
||||||
|
configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROO_CODE_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'roo-code',
|
||||||
|
ideLabel: 'Roo Code',
|
||||||
|
configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const WARP_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'warp',
|
||||||
|
ideLabel: 'Warp',
|
||||||
|
configPath: path.join(homedir(), '.warp', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), 'WARP.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Goose (YAML-based — separate handler)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Goose config path.
|
||||||
|
* Goose stores its config at ~/.config/goose/config.yaml.
|
||||||
|
*/
|
||||||
|
function getGooseConfigPath(): string {
|
||||||
|
return path.join(homedir(), '.config', 'goose', 'config.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a YAML string already has a claude-mem entry under mcpServers.
|
||||||
|
* Uses string matching to avoid needing a YAML parser.
|
||||||
|
*/
|
||||||
|
function gooseConfigHasClaudeMemEntry(yamlContent: string): boolean {
|
||||||
|
// Look for "claude-mem:" indented under mcpServers
|
||||||
|
return yamlContent.includes('claude-mem:') &&
|
||||||
|
yamlContent.includes('mcpServers:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Goose YAML MCP server block as a string.
|
||||||
|
* Produces properly indented YAML without needing a parser.
|
||||||
|
*/
|
||||||
|
function buildGooseMcpYamlBlock(mcpServerPath: string): string {
|
||||||
|
// Goose expects the mcpServers section at the top level
|
||||||
|
return [
|
||||||
|
'mcpServers:',
|
||||||
|
' claude-mem:',
|
||||||
|
` command: ${process.execPath}`,
|
||||||
|
' args:',
|
||||||
|
` - ${mcpServerPath}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build just the claude-mem server entry (for appending under existing mcpServers).
|
||||||
|
*/
|
||||||
|
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
|
||||||
|
return [
|
||||||
|
' claude-mem:',
|
||||||
|
` command: ${process.execPath}`,
|
||||||
|
' args:',
|
||||||
|
` - ${mcpServerPath}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install claude-mem MCP integration for Goose.
|
||||||
|
*
|
||||||
|
* - Writes/merges MCP config into ~/.config/goose/config.yaml
|
||||||
|
* - Uses string manipulation for YAML (no parser dependency)
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installGooseMcpIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem MCP integration for Goose...\n');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configPath = getGooseConfigPath();
|
||||||
|
const configDirectory = path.dirname(configPath);
|
||||||
|
mkdirSync(configDirectory, { recursive: true });
|
||||||
|
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
let yamlContent = readFileSync(configPath, 'utf-8');
|
||||||
|
|
||||||
|
if (gooseConfigHasClaudeMemEntry(yamlContent)) {
|
||||||
|
// Already configured — replace the claude-mem block
|
||||||
|
// Find the claude-mem entry and replace it
|
||||||
|
const claudeMemPattern = /( {2}claude-mem:\n(?:.*\n)*?(?= {2}\S|\n\n|^\S|$))/m;
|
||||||
|
const newEntry = buildGooseClaudeMemEntryYaml(mcpServerPath) + '\n';
|
||||||
|
|
||||||
|
if (claudeMemPattern.test(yamlContent)) {
|
||||||
|
yamlContent = yamlContent.replace(claudeMemPattern, newEntry);
|
||||||
|
}
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Updated existing claude-mem entry in: ${configPath}`);
|
||||||
|
} else if (yamlContent.includes('mcpServers:')) {
|
||||||
|
// mcpServers section exists but no claude-mem entry — append under it
|
||||||
|
const mcpServersIndex = yamlContent.indexOf('mcpServers:');
|
||||||
|
const insertionPoint = mcpServersIndex + 'mcpServers:'.length;
|
||||||
|
const newEntry = '\n' + buildGooseClaudeMemEntryYaml(mcpServerPath);
|
||||||
|
|
||||||
|
yamlContent =
|
||||||
|
yamlContent.slice(0, insertionPoint) +
|
||||||
|
newEntry +
|
||||||
|
yamlContent.slice(insertionPoint);
|
||||||
|
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Added claude-mem to existing mcpServers in: ${configPath}`);
|
||||||
|
} else {
|
||||||
|
// No mcpServers section — append the entire block
|
||||||
|
const mcpBlock = '\n' + buildGooseMcpYamlBlock(mcpServerPath) + '\n';
|
||||||
|
yamlContent = yamlContent.trimEnd() + '\n' + mcpBlock;
|
||||||
|
writeFileSync(configPath, yamlContent);
|
||||||
|
console.log(` Appended mcpServers section to: ${configPath}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// File doesn't exist — create from template
|
||||||
|
const templateContent = buildGooseMcpYamlBlock(mcpServerPath) + '\n';
|
||||||
|
writeFileSync(configPath, templateContent);
|
||||||
|
console.log(` Created config with MCP server: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
MCP config: ${configPath}
|
||||||
|
|
||||||
|
Note: This is an MCP-only integration providing search tools and context.
|
||||||
|
Transcript capture is not available for Goose.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart Goose to pick up the MCP server
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unified Installer (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of IDE identifiers to their install functions.
|
||||||
|
* Used by the install command to dispatch to the correct integration.
|
||||||
|
*/
|
||||||
|
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
|
||||||
|
'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
|
||||||
|
'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
|
||||||
|
'goose': installGooseMcpIntegration,
|
||||||
|
'crush': installMcpIntegration(CRUSH_CONFIG),
|
||||||
|
'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
|
||||||
|
'warp': installMcpIntegration(WARP_CONFIG),
|
||||||
|
};
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* OpenClawInstaller - OpenClaw gateway integration installer for claude-mem
|
||||||
|
*
|
||||||
|
* Installs the pre-built claude-mem plugin into OpenClaw's extension directory
|
||||||
|
* and registers it in ~/.openclaw/openclaw.json.
|
||||||
|
*
|
||||||
|
* Install strategy: File-based
|
||||||
|
* - Copies the pre-built plugin from the npm package's openclaw/dist/ directory
|
||||||
|
* to ~/.openclaw/extensions/claude-mem/dist/
|
||||||
|
* - Registers the plugin in openclaw.json under plugins.entries.claude-mem
|
||||||
|
* - Sets the memory slot to claude-mem
|
||||||
|
*
|
||||||
|
* Important: The OpenClaw plugin ships pre-built from the npm package.
|
||||||
|
* It must NOT be rebuilt at install time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
cpSync,
|
||||||
|
rmSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Resolution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenClaw config directory (~/.openclaw).
|
||||||
|
*/
|
||||||
|
export function getOpenClawConfigDirectory(): string {
|
||||||
|
return path.join(homedir(), '.openclaw');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenClaw extensions directory where plugins are installed.
|
||||||
|
*/
|
||||||
|
export function getOpenClawExtensionsDirectory(): string {
|
||||||
|
return path.join(getOpenClawConfigDirectory(), 'extensions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the claude-mem extension install directory.
|
||||||
|
*/
|
||||||
|
export function getOpenClawClaudeMemExtensionDirectory(): string {
|
||||||
|
return path.join(getOpenClawExtensionsDirectory(), 'claude-mem');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the path to openclaw.json config file.
|
||||||
|
*/
|
||||||
|
export function getOpenClawConfigFilePath(): string {
|
||||||
|
return path.join(getOpenClawConfigDirectory(), 'openclaw.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pre-built Plugin Location
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the pre-built OpenClaw plugin bundle in the npm package.
|
||||||
|
* Searches in: openclaw/dist/index.js relative to package root,
|
||||||
|
* then the marketplace install location.
|
||||||
|
*/
|
||||||
|
export function findPreBuiltPluginDirectory(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
// Marketplace install location (production — after `npx claude-mem install`)
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
// Development location (relative to project root)
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const openclawDistDirectory = path.join(root, 'openclaw', 'dist');
|
||||||
|
const pluginEntryPoint = path.join(openclawDistDirectory, 'index.js');
|
||||||
|
if (existsSync(pluginEntryPoint)) {
|
||||||
|
return openclawDistDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the openclaw.plugin.json file for copying alongside the plugin.
|
||||||
|
*/
|
||||||
|
export function findPluginManifestPath(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const manifestPath = path.join(root, 'openclaw', 'openclaw.plugin.json');
|
||||||
|
if (existsSync(manifestPath)) {
|
||||||
|
return manifestPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the openclaw skills directory for copying alongside the plugin.
|
||||||
|
*/
|
||||||
|
export function findPluginSkillsDirectory(): string | null {
|
||||||
|
const possibleRoots = [
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
),
|
||||||
|
process.cwd(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of possibleRoots) {
|
||||||
|
const skillsDirectory = path.join(root, 'openclaw', 'skills');
|
||||||
|
if (existsSync(skillsDirectory)) {
|
||||||
|
return skillsDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Config (openclaw.json) Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read openclaw.json safely, returning an empty object if missing or invalid.
|
||||||
|
*/
|
||||||
|
function readOpenClawConfig(): Record<string, any> {
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
if (!existsSync(configFilePath)) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(configFilePath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write openclaw.json atomically, creating the directory if needed.
|
||||||
|
*/
|
||||||
|
function writeOpenClawConfig(config: Record<string, any>): void {
|
||||||
|
const configDirectory = getOpenClawConfigDirectory();
|
||||||
|
mkdirSync(configDirectory, { recursive: true });
|
||||||
|
writeFileSync(getOpenClawConfigFilePath(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register claude-mem in openclaw.json by merging into the existing config.
|
||||||
|
* Does NOT overwrite the entire file -- only touches the claude-mem entry
|
||||||
|
* and the memory slot.
|
||||||
|
*/
|
||||||
|
function registerPluginInOpenClawConfig(
|
||||||
|
workerPort: number = 37777,
|
||||||
|
project: string = 'openclaw',
|
||||||
|
syncMemoryFile: boolean = true,
|
||||||
|
): void {
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Ensure the plugins structure exists
|
||||||
|
if (!config.plugins) config.plugins = {};
|
||||||
|
if (!config.plugins.slots) config.plugins.slots = {};
|
||||||
|
if (!config.plugins.entries) config.plugins.entries = {};
|
||||||
|
|
||||||
|
// Set the memory slot to claude-mem
|
||||||
|
config.plugins.slots.memory = 'claude-mem';
|
||||||
|
|
||||||
|
// Create or update the claude-mem plugin entry
|
||||||
|
if (!config.plugins.entries['claude-mem']) {
|
||||||
|
config.plugins.entries['claude-mem'] = {
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
workerPort,
|
||||||
|
project,
|
||||||
|
syncMemoryFile,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Merge: enable and update config without losing existing user settings
|
||||||
|
config.plugins.entries['claude-mem'].enabled = true;
|
||||||
|
if (!config.plugins.entries['claude-mem'].config) {
|
||||||
|
config.plugins.entries['claude-mem'].config = {};
|
||||||
|
}
|
||||||
|
const existingPluginConfig = config.plugins.entries['claude-mem'].config;
|
||||||
|
// Only set defaults if not already configured
|
||||||
|
if (existingPluginConfig.workerPort === undefined) existingPluginConfig.workerPort = workerPort;
|
||||||
|
if (existingPluginConfig.project === undefined) existingPluginConfig.project = project;
|
||||||
|
if (existingPluginConfig.syncMemoryFile === undefined) existingPluginConfig.syncMemoryFile = syncMemoryFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOpenClawConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove claude-mem from openclaw.json without deleting other config.
|
||||||
|
*/
|
||||||
|
function unregisterPluginFromOpenClawConfig(): void {
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
if (!existsSync(configFilePath)) return;
|
||||||
|
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Remove claude-mem entry
|
||||||
|
if (config.plugins?.entries?.['claude-mem']) {
|
||||||
|
delete config.plugins.entries['claude-mem'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear memory slot if it points to claude-mem
|
||||||
|
if (config.plugins?.slots?.memory === 'claude-mem') {
|
||||||
|
delete config.plugins.slots.memory;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeOpenClawConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the claude-mem plugin into OpenClaw's extensions directory.
|
||||||
|
* Copies the pre-built plugin bundle and registers it in openclaw.json.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function installOpenClawPlugin(): number {
|
||||||
|
const preBuiltDistDirectory = findPreBuiltPluginDirectory();
|
||||||
|
if (!preBuiltDistDirectory) {
|
||||||
|
console.error('Could not find pre-built OpenClaw plugin bundle.');
|
||||||
|
console.error(' Expected at: openclaw/dist/index.js');
|
||||||
|
console.error(' Ensure the npm package includes the openclaw directory.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
const destinationDistDirectory = path.join(extensionDirectory, 'dist');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the extension directory structure
|
||||||
|
mkdirSync(destinationDistDirectory, { recursive: true });
|
||||||
|
|
||||||
|
// Copy pre-built dist files
|
||||||
|
cpSync(preBuiltDistDirectory, destinationDistDirectory, { recursive: true, force: true });
|
||||||
|
console.log(` Plugin dist copied to: ${destinationDistDirectory}`);
|
||||||
|
|
||||||
|
// Copy openclaw.plugin.json if available
|
||||||
|
const manifestPath = findPluginManifestPath();
|
||||||
|
if (manifestPath) {
|
||||||
|
const destinationManifest = path.join(extensionDirectory, 'openclaw.plugin.json');
|
||||||
|
cpSync(manifestPath, destinationManifest, { force: true });
|
||||||
|
console.log(` Plugin manifest copied to: ${destinationManifest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy skills directory if available
|
||||||
|
const skillsDirectory = findPluginSkillsDirectory();
|
||||||
|
if (skillsDirectory) {
|
||||||
|
const destinationSkills = path.join(extensionDirectory, 'skills');
|
||||||
|
cpSync(skillsDirectory, destinationSkills, { recursive: true, force: true });
|
||||||
|
console.log(` Skills copied to: ${destinationSkills}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal package.json for the extension (OpenClaw expects this)
|
||||||
|
const extensionPackageJson = {
|
||||||
|
name: 'claude-mem',
|
||||||
|
version: '1.0.0',
|
||||||
|
type: 'module',
|
||||||
|
main: 'dist/index.js',
|
||||||
|
openclaw: { extensions: ['./dist/index.js'] },
|
||||||
|
};
|
||||||
|
writeFileSync(
|
||||||
|
path.join(extensionDirectory, 'package.json'),
|
||||||
|
JSON.stringify(extensionPackageJson, null, 2) + '\n',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register in openclaw.json (merge, not overwrite)
|
||||||
|
registerPluginInOpenClawConfig();
|
||||||
|
console.log(` Registered in openclaw.json`);
|
||||||
|
|
||||||
|
logger.info('OPENCLAW', 'Plugin installed', { destination: extensionDirectory });
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to install OpenClaw plugin: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uninstallation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the claude-mem plugin from OpenClaw.
|
||||||
|
* Removes extension files and unregisters from openclaw.json.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallOpenClawPlugin(): number {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Remove extension directory
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
if (existsSync(extensionDirectory)) {
|
||||||
|
try {
|
||||||
|
rmSync(extensionDirectory, { recursive: true, force: true });
|
||||||
|
console.log(` Removed extension: ${extensionDirectory}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to remove extension directory: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister from openclaw.json
|
||||||
|
try {
|
||||||
|
unregisterPluginFromOpenClawConfig();
|
||||||
|
console.log(` Unregistered from openclaw.json`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to update openclaw.json: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasErrors ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OpenClaw integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational only)
|
||||||
|
*/
|
||||||
|
export function checkOpenClawStatus(): number {
|
||||||
|
console.log('\nClaude-Mem OpenClaw Integration Status\n');
|
||||||
|
|
||||||
|
const configDirectory = getOpenClawConfigDirectory();
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
const configFilePath = getOpenClawConfigFilePath();
|
||||||
|
const pluginEntryPoint = path.join(extensionDirectory, 'dist', 'index.js');
|
||||||
|
|
||||||
|
console.log(`Config directory: ${configDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Extension directory: ${extensionDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(extensionDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Plugin entry: ${existsSync(pluginEntryPoint) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Config (openclaw.json): ${configFilePath}`);
|
||||||
|
if (existsSync(configFilePath)) {
|
||||||
|
const config = readOpenClawConfig();
|
||||||
|
const isRegistered = config.plugins?.entries?.['claude-mem'] !== undefined;
|
||||||
|
const isEnabled = config.plugins?.entries?.['claude-mem']?.enabled === true;
|
||||||
|
const isMemorySlot = config.plugins?.slots?.memory === 'claude-mem';
|
||||||
|
|
||||||
|
console.log(` Exists: yes`);
|
||||||
|
console.log(` Registered: ${isRegistered ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Enabled: ${isEnabled ? 'yes' : 'no'}`);
|
||||||
|
console.log(` Memory slot: ${isMemorySlot ? 'yes' : 'no'}`);
|
||||||
|
|
||||||
|
if (isRegistered) {
|
||||||
|
const pluginConfig = config.plugins.entries['claude-mem'].config;
|
||||||
|
if (pluginConfig) {
|
||||||
|
console.log(` Worker port: ${pluginConfig.workerPort ?? 'default'}`);
|
||||||
|
console.log(` Project: ${pluginConfig.project ?? 'default'}`);
|
||||||
|
console.log(` Sync MEMORY.md: ${pluginConfig.syncMemoryFile ?? 'default'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` Exists: no`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Full Install Flow (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full OpenClaw installation: copy plugin + register in config.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installOpenClawIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for OpenClaw...\n');
|
||||||
|
|
||||||
|
// Step 1: Install plugin files and register in config
|
||||||
|
const pluginResult = installOpenClawPlugin();
|
||||||
|
if (pluginResult !== 0) {
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionDirectory = getOpenClawClaudeMemExtensionDirectory();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Plugin installed to: ${extensionDirectory}
|
||||||
|
Config updated: ${getOpenClawConfigFilePath()}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart OpenClaw to load the plugin
|
||||||
|
3. Memory capture is automatic from then on
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* OpenCodeInstaller - OpenCode IDE integration installer for claude-mem
|
||||||
|
*
|
||||||
|
* Installs the claude-mem plugin into OpenCode's plugin directory and
|
||||||
|
* sets up context injection via AGENTS.md.
|
||||||
|
*
|
||||||
|
* Install strategy: File-based (Option A)
|
||||||
|
* - Copies the built plugin to the OpenCode plugins directory
|
||||||
|
* - Plugins in that directory are auto-loaded at startup
|
||||||
|
*
|
||||||
|
* Context injection:
|
||||||
|
* - Appends/updates <claude-mem-context> section in AGENTS.md
|
||||||
|
*
|
||||||
|
* Respects OPENCODE_CONFIG_DIR env var for config directory resolution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Resolution
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenCode config directory.
|
||||||
|
* Respects OPENCODE_CONFIG_DIR env var, falls back to ~/.config/opencode.
|
||||||
|
*/
|
||||||
|
export function getOpenCodeConfigDirectory(): string {
|
||||||
|
if (process.env.OPENCODE_CONFIG_DIR) {
|
||||||
|
return process.env.OPENCODE_CONFIG_DIR;
|
||||||
|
}
|
||||||
|
return path.join(homedir(), '.config', 'opencode');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenCode plugins directory.
|
||||||
|
*/
|
||||||
|
export function getOpenCodePluginsDirectory(): string {
|
||||||
|
return path.join(getOpenCodeConfigDirectory(), 'plugins');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the AGENTS.md path for context injection.
|
||||||
|
*/
|
||||||
|
export function getOpenCodeAgentsMdPath(): string {
|
||||||
|
return path.join(getOpenCodeConfigDirectory(), 'AGENTS.md');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the path to the installed plugin file.
|
||||||
|
*/
|
||||||
|
export function getInstalledPluginPath(): string {
|
||||||
|
return path.join(getOpenCodePluginsDirectory(), 'claude-mem.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Plugin Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the built OpenCode plugin bundle.
|
||||||
|
* Searches in: dist/opencode-plugin/index.js (built output),
|
||||||
|
* then marketplace location.
|
||||||
|
*/
|
||||||
|
export function findBuiltPluginPath(): string | null {
|
||||||
|
const possiblePaths = [
|
||||||
|
// Marketplace install location (production)
|
||||||
|
path.join(
|
||||||
|
process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), '.claude'),
|
||||||
|
'plugins', 'marketplaces', 'thedotmack',
|
||||||
|
'dist', 'opencode-plugin', 'index.js',
|
||||||
|
),
|
||||||
|
// Development location (relative to this module's package root)
|
||||||
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'dist', 'opencode-plugin', 'index.js'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidatePath of possiblePaths) {
|
||||||
|
if (existsSync(candidatePath)) {
|
||||||
|
return candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the claude-mem plugin into OpenCode's plugins directory.
|
||||||
|
* Copies the built plugin bundle to ~/.config/opencode/plugins/claude-mem.js
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function installOpenCodePlugin(): number {
|
||||||
|
const builtPluginPath = findBuiltPluginPath();
|
||||||
|
if (!builtPluginPath) {
|
||||||
|
console.error('Could not find built OpenCode plugin bundle.');
|
||||||
|
console.error(' Expected at: dist/opencode-plugin/index.js');
|
||||||
|
console.error(' Run the build first: npm run build');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDirectory = getOpenCodePluginsDirectory();
|
||||||
|
const destinationPath = getInstalledPluginPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create plugins directory if needed
|
||||||
|
mkdirSync(pluginsDirectory, { recursive: true });
|
||||||
|
|
||||||
|
// Copy plugin bundle
|
||||||
|
copyFileSync(builtPluginPath, destinationPath);
|
||||||
|
|
||||||
|
console.log(` Plugin installed to: ${destinationPath}`);
|
||||||
|
logger.info('OPENCODE', 'Plugin installed', { destination: destinationPath });
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to install OpenCode plugin: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Injection (AGENTS.md)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
|
||||||
|
*
|
||||||
|
* If the file doesn't exist, creates it with the context section.
|
||||||
|
* If the file exists, replaces the existing <claude-mem-context> section
|
||||||
|
* or appends one at the end.
|
||||||
|
*
|
||||||
|
* @param contextContent - The context content to inject (without tags)
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function injectContextIntoAgentsMd(contextContent: string): number {
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
|
||||||
|
try {
|
||||||
|
injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
|
||||||
|
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`Failed to inject context into AGENTS.md: ${message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync context from the worker into OpenCode's AGENTS.md.
|
||||||
|
* Fetches context from the worker API and writes it to AGENTS.md.
|
||||||
|
*
|
||||||
|
* @param port - Worker port number
|
||||||
|
* @param project - Project name for context filtering
|
||||||
|
*/
|
||||||
|
export async function syncContextToAgentsMd(
|
||||||
|
port: number,
|
||||||
|
project: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const contextText = await response.text();
|
||||||
|
if (contextText && contextText.trim()) {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(contextText);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject context into AGENTS.md during sync');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Worker not available — non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Uninstallation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the claude-mem plugin from OpenCode.
|
||||||
|
* Removes the plugin file and cleans up the AGENTS.md context section.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export function uninstallOpenCodePlugin(): number {
|
||||||
|
let hasErrors = false;
|
||||||
|
|
||||||
|
// Remove plugin file
|
||||||
|
const pluginPath = getInstalledPluginPath();
|
||||||
|
if (existsSync(pluginPath)) {
|
||||||
|
try {
|
||||||
|
unlinkSync(pluginPath);
|
||||||
|
console.log(` Removed plugin: ${pluginPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to remove plugin: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove context section from AGENTS.md
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
if (existsSync(agentsMdPath)) {
|
||||||
|
try {
|
||||||
|
let content = readFileSync(agentsMdPath, 'utf-8');
|
||||||
|
const tagStartIndex = content.indexOf(CONTEXT_TAG_OPEN);
|
||||||
|
const tagEndIndex = content.indexOf(CONTEXT_TAG_CLOSE);
|
||||||
|
|
||||||
|
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
||||||
|
content =
|
||||||
|
content.slice(0, tagStartIndex).trimEnd() +
|
||||||
|
'\n' +
|
||||||
|
content.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length).trimStart();
|
||||||
|
|
||||||
|
// If the file is now essentially empty or only has our header, remove it
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (
|
||||||
|
trimmedContent.length === 0 ||
|
||||||
|
trimmedContent === '# Claude-Mem Memory Context'
|
||||||
|
) {
|
||||||
|
unlinkSync(agentsMdPath);
|
||||||
|
console.log(` Removed empty AGENTS.md`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(agentsMdPath, trimmedContent + '\n', 'utf-8');
|
||||||
|
console.log(` Cleaned context from AGENTS.md`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(` Failed to clean AGENTS.md: ${message}`);
|
||||||
|
hasErrors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasErrors ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Check
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OpenCode integration status.
|
||||||
|
*
|
||||||
|
* @returns 0 always (informational only)
|
||||||
|
*/
|
||||||
|
export function checkOpenCodeStatus(): number {
|
||||||
|
console.log('\nClaude-Mem OpenCode Integration Status\n');
|
||||||
|
|
||||||
|
const configDirectory = getOpenCodeConfigDirectory();
|
||||||
|
const pluginPath = getInstalledPluginPath();
|
||||||
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
|
|
||||||
|
console.log(`Config directory: ${configDirectory}`);
|
||||||
|
console.log(` Exists: ${existsSync(configDirectory) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Plugin: ${pluginPath}`);
|
||||||
|
console.log(` Installed: ${existsSync(pluginPath) ? 'yes' : 'no'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log(`Context (AGENTS.md): ${agentsMdPath}`);
|
||||||
|
if (existsSync(agentsMdPath)) {
|
||||||
|
const content = readFileSync(agentsMdPath, 'utf-8');
|
||||||
|
const hasContextTags = content.includes(CONTEXT_TAG_OPEN);
|
||||||
|
console.log(` Exists: yes`);
|
||||||
|
console.log(` Has claude-mem context: ${hasContextTags ? 'yes' : 'no'}`);
|
||||||
|
} else {
|
||||||
|
console.log(` Exists: no`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Full Install Flow (used by npx install command)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full OpenCode installation: plugin + context injection.
|
||||||
|
*
|
||||||
|
* @returns 0 on success, 1 on failure
|
||||||
|
*/
|
||||||
|
export async function installOpenCodeIntegration(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem for OpenCode...\n');
|
||||||
|
|
||||||
|
// Step 1: Install plugin
|
||||||
|
const pluginResult = installOpenCodePlugin();
|
||||||
|
if (pluginResult !== 0) {
|
||||||
|
return pluginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create initial context in AGENTS.md
|
||||||
|
const placeholderContext = `# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
|
||||||
|
Use claude-mem search tools for manual memory queries.`;
|
||||||
|
|
||||||
|
// Try to fetch real context from worker first
|
||||||
|
try {
|
||||||
|
const workerPort = getWorkerPort();
|
||||||
|
const healthResponse = await fetch(`http://127.0.0.1:${workerPort}/api/readiness`);
|
||||||
|
if (healthResponse.ok) {
|
||||||
|
const contextResponse = await fetch(
|
||||||
|
`http://127.0.0.1:${workerPort}/api/context/inject?project=opencode`,
|
||||||
|
);
|
||||||
|
if (contextResponse.ok) {
|
||||||
|
const realContext = await contextResponse.text();
|
||||||
|
if (realContext && realContext.trim()) {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(realContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject real context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Context injected from existing memory');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (will populate after first session)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (worker not running)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const injectResult = injectContextIntoAgentsMd(placeholderContext);
|
||||||
|
if (injectResult !== 0) {
|
||||||
|
logger.warn('OPENCODE', 'Failed to inject placeholder context into AGENTS.md during install');
|
||||||
|
} else {
|
||||||
|
console.log(' Placeholder context created (worker not running)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Plugin installed to: ${getInstalledPluginPath()}
|
||||||
|
Context file: ${getOpenCodeAgentsMdPath()}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: npx claude-mem start
|
||||||
|
2. Restart OpenCode to load the plugin
|
||||||
|
3. Memory capture is automatic from then on
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* WindsurfHooksInstaller - Windsurf IDE integration for claude-mem
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - Windsurf hooks installation/uninstallation to ~/.codeium/windsurf/hooks.json
|
||||||
|
* - Context file generation (.windsurf/rules/claude-mem-context.md)
|
||||||
|
* - Project registry management for auto-context updates
|
||||||
|
*
|
||||||
|
* Windsurf hooks.json format:
|
||||||
|
* {
|
||||||
|
* "hooks": {
|
||||||
|
* "<event_name>": [{ "command": "...", "show_output": false, "working_directory": "..." }]
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Events registered (all post-action, non-blocking):
|
||||||
|
* - pre_user_prompt — session init + context injection
|
||||||
|
* - post_write_code — code generation observation
|
||||||
|
* - post_run_command — command execution observation
|
||||||
|
* - post_mcp_tool_use — MCP tool results
|
||||||
|
* - post_cascade_response — full AI response
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync } from 'fs';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||||
|
import { DATA_DIR } from '../../shared/paths.js';
|
||||||
|
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface WindsurfHookEntry {
|
||||||
|
command: string;
|
||||||
|
show_output: boolean;
|
||||||
|
working_directory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindsurfHooksJson {
|
||||||
|
hooks: {
|
||||||
|
[eventName: string]: WindsurfHookEntry[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindsurfProjectRegistry {
|
||||||
|
[workspacePath: string]: {
|
||||||
|
installedAt: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** User-level hooks config — global coverage across all Windsurf workspaces */
|
||||||
|
const WINDSURF_HOOKS_DIR = path.join(homedir(), '.codeium', 'windsurf');
|
||||||
|
const WINDSURF_HOOKS_JSON_PATH = path.join(WINDSURF_HOOKS_DIR, 'hooks.json');
|
||||||
|
|
||||||
|
/** Windsurf context rule limit: 6,000 chars per file */
|
||||||
|
const WINDSURF_CONTEXT_CHAR_LIMIT = 6000;
|
||||||
|
|
||||||
|
/** Registry file for tracking projects with Windsurf hooks */
|
||||||
|
const WINDSURF_REGISTRY_FILE = path.join(DATA_DIR, 'windsurf-projects.json');
|
||||||
|
|
||||||
|
/** Hook events we register */
|
||||||
|
const WINDSURF_HOOK_EVENTS = [
|
||||||
|
'pre_user_prompt',
|
||||||
|
'post_write_code',
|
||||||
|
'post_run_command',
|
||||||
|
'post_mcp_tool_use',
|
||||||
|
'post_cascade_response',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Project Registry
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the Windsurf project registry
|
||||||
|
*/
|
||||||
|
export function readWindsurfRegistry(): WindsurfProjectRegistry {
|
||||||
|
try {
|
||||||
|
if (!existsSync(WINDSURF_REGISTRY_FILE)) return {};
|
||||||
|
return JSON.parse(readFileSync(WINDSURF_REGISTRY_FILE, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('WINDSURF', 'Failed to read registry, using empty', {
|
||||||
|
file: WINDSURF_REGISTRY_FILE,
|
||||||
|
}, error as Error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the Windsurf project registry
|
||||||
|
*/
|
||||||
|
export function writeWindsurfRegistry(registry: WindsurfProjectRegistry): void {
|
||||||
|
const dir = path.dirname(WINDSURF_REGISTRY_FILE);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(WINDSURF_REGISTRY_FILE, JSON.stringify(registry, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a project for auto-context updates.
|
||||||
|
* Keys by full workspacePath to avoid collisions between directories with the same basename.
|
||||||
|
*/
|
||||||
|
export function registerWindsurfProject(workspacePath: string): void {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
registry[workspacePath] = {
|
||||||
|
installedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeWindsurfRegistry(registry);
|
||||||
|
logger.info('WINDSURF', 'Registered project for auto-context updates', { workspacePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a project from auto-context updates
|
||||||
|
*/
|
||||||
|
export function unregisterWindsurfProject(workspacePath: string): void {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
if (registry[workspacePath]) {
|
||||||
|
delete registry[workspacePath];
|
||||||
|
writeWindsurfRegistry(registry);
|
||||||
|
logger.info('WINDSURF', 'Unregistered project', { workspacePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Windsurf context files for a registered project.
|
||||||
|
* Called by SDK agents after saving a summary.
|
||||||
|
*/
|
||||||
|
export async function updateWindsurfContextForProject(projectName: string, workspacePath: string, port: number): Promise<void> {
|
||||||
|
const registry = readWindsurfRegistry();
|
||||||
|
const entry = registry[workspacePath];
|
||||||
|
|
||||||
|
if (!entry) return; // Project doesn't have Windsurf hooks installed
|
||||||
|
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
|
||||||
|
writeWindsurfContextFile(workspacePath, context);
|
||||||
|
logger.debug('WINDSURF', 'Updated context file', { projectName, workspacePath });
|
||||||
|
} catch (error) {
|
||||||
|
// Background context update — failure is non-critical
|
||||||
|
logger.error('WINDSURF', 'Failed to update context file', { projectName, workspacePath }, error as Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context File
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write context to the workspace-level Windsurf rules directory.
|
||||||
|
* Windsurf rules are workspace-scoped: .windsurf/rules/claude-mem-context.md
|
||||||
|
* Rule file limit: 6,000 chars per file.
|
||||||
|
*/
|
||||||
|
export function writeWindsurfContextFile(workspacePath: string, context: string): void {
|
||||||
|
const rulesDir = path.join(workspacePath, '.windsurf', 'rules');
|
||||||
|
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
|
||||||
|
const tempFile = `${rulesFile}.tmp`;
|
||||||
|
|
||||||
|
mkdirSync(rulesDir, { recursive: true });
|
||||||
|
|
||||||
|
let content = `# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||||
|
|
||||||
|
${context}
|
||||||
|
|
||||||
|
---
|
||||||
|
*Auto-updated by claude-mem after each session. Use MCP search tools for detailed queries.*
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Enforce Windsurf's 6K char limit
|
||||||
|
if (content.length > WINDSURF_CONTEXT_CHAR_LIMIT) {
|
||||||
|
content = content.slice(0, WINDSURF_CONTEXT_CHAR_LIMIT - 50) +
|
||||||
|
'\n\n*[Truncated — use MCP search for full history]*\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic write: temp file + rename
|
||||||
|
writeFileSync(tempFile, content);
|
||||||
|
renameSync(tempFile, rulesFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook Installation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the hook command string for a given event.
|
||||||
|
* Uses bun to run worker-service.cjs with the windsurf platform adapter.
|
||||||
|
*/
|
||||||
|
function buildHookCommand(bunPath: string, workerServicePath: string, eventName: string): string {
|
||||||
|
// Map Windsurf event names to unified CLI hook commands
|
||||||
|
const eventToCommand: Record<string, string> = {
|
||||||
|
'pre_user_prompt': 'session-init',
|
||||||
|
'post_write_code': 'file-edit',
|
||||||
|
'post_run_command': 'observation',
|
||||||
|
'post_mcp_tool_use': 'observation',
|
||||||
|
'post_cascade_response': 'observation',
|
||||||
|
};
|
||||||
|
|
||||||
|
const hookCommand = eventToCommand[eventName] ?? 'observation';
|
||||||
|
|
||||||
|
return `"${bunPath}" "${workerServicePath}" hook windsurf ${hookCommand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read existing hooks.json, merge our hooks, and write back.
|
||||||
|
* Preserves any existing hooks from other tools.
|
||||||
|
*/
|
||||||
|
function mergeAndWriteHooksJson(
|
||||||
|
bunPath: string,
|
||||||
|
workerServicePath: string,
|
||||||
|
workingDirectory: string,
|
||||||
|
): void {
|
||||||
|
mkdirSync(WINDSURF_HOOKS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Read existing hooks.json if present
|
||||||
|
let existingConfig: WindsurfHooksJson = { hooks: {} };
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
existingConfig = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
if (!existingConfig.hooks) {
|
||||||
|
existingConfig.hooks = {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt hooks.json at ${WINDSURF_HOOKS_JSON_PATH}, refusing to overwrite`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each event, add our hook entry (remove any previous claude-mem entries first)
|
||||||
|
for (const eventName of WINDSURF_HOOK_EVENTS) {
|
||||||
|
const command = buildHookCommand(bunPath, workerServicePath, eventName);
|
||||||
|
|
||||||
|
const hookEntry: WindsurfHookEntry = {
|
||||||
|
command,
|
||||||
|
show_output: false,
|
||||||
|
working_directory: workingDirectory,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get existing hooks for this event, filtering out old claude-mem ones
|
||||||
|
const existingHooks = (existingConfig.hooks[eventName] ?? []).filter(
|
||||||
|
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
|
||||||
|
);
|
||||||
|
|
||||||
|
existingConfig.hooks[eventName] = [...existingHooks, hookEntry];
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(existingConfig, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install Windsurf hooks to ~/.codeium/windsurf/hooks.json (user-level).
|
||||||
|
* Merges with existing hooks.json to preserve other integrations.
|
||||||
|
*/
|
||||||
|
export async function installWindsurfHooks(): Promise<number> {
|
||||||
|
console.log('\nInstalling Claude-Mem Windsurf hooks (user level)...\n');
|
||||||
|
|
||||||
|
// Find the worker-service.cjs path
|
||||||
|
const workerServicePath = findWorkerServicePath();
|
||||||
|
if (!workerServicePath) {
|
||||||
|
console.error('Could not find worker-service.cjs');
|
||||||
|
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/worker-service.cjs');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find bun executable — required because worker-service.cjs uses bun:sqlite
|
||||||
|
const bunPath = findBunPath();
|
||||||
|
if (!bunPath) {
|
||||||
|
console.error('Could not find Bun runtime');
|
||||||
|
console.error(' Install Bun: curl -fsSL https://bun.sh/install | bash');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Tilde expansion is NOT supported in working_directory — use absolute paths
|
||||||
|
const workingDirectory = path.dirname(workerServicePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(` Using Bun runtime: ${bunPath}`);
|
||||||
|
console.log(` Worker service: ${workerServicePath}`);
|
||||||
|
|
||||||
|
// Merge our hooks into the existing hooks.json
|
||||||
|
mergeAndWriteHooksJson(bunPath, workerServicePath, workingDirectory);
|
||||||
|
console.log(` Created/merged hooks.json`);
|
||||||
|
|
||||||
|
// Set up initial context for the current workspace
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
await setupWindsurfProjectContext(workspaceRoot);
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Installation complete!
|
||||||
|
|
||||||
|
Hooks installed to: ${WINDSURF_HOOKS_JSON_PATH}
|
||||||
|
Using unified CLI: bun worker-service.cjs hook windsurf <command>
|
||||||
|
|
||||||
|
Events registered:
|
||||||
|
- pre_user_prompt (session init + context injection)
|
||||||
|
- post_write_code (code generation observation)
|
||||||
|
- post_run_command (command execution observation)
|
||||||
|
- post_mcp_tool_use (MCP tool results)
|
||||||
|
- post_cascade_response (full AI response)
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Start claude-mem worker: claude-mem start
|
||||||
|
2. Restart Windsurf to load the hooks
|
||||||
|
3. Context is injected via .windsurf/rules/claude-mem-context.md (workspace-level)
|
||||||
|
`);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup initial context file for a Windsurf workspace
|
||||||
|
*/
|
||||||
|
async function setupWindsurfProjectContext(workspaceRoot: string): Promise<void> {
|
||||||
|
const port = getWorkerPort();
|
||||||
|
const projectName = path.basename(workspaceRoot);
|
||||||
|
let contextGenerated = false;
|
||||||
|
|
||||||
|
console.log(` Generating initial context...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||||
|
if (healthResponse.ok) {
|
||||||
|
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()) {
|
||||||
|
writeWindsurfContextFile(workspaceRoot, context);
|
||||||
|
contextGenerated = true;
|
||||||
|
console.log(` Generated initial context from existing memory`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Worker not running during install — non-critical
|
||||||
|
logger.debug('WINDSURF', 'Worker not running during install', {}, error as Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contextGenerated) {
|
||||||
|
// Create placeholder context file
|
||||||
|
const rulesDir = path.join(workspaceRoot, '.windsurf', 'rules');
|
||||||
|
mkdirSync(rulesDir, { recursive: true });
|
||||||
|
const rulesFile = path.join(rulesDir, 'claude-mem-context.md');
|
||||||
|
const placeholderContent = `# 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
|
||||||
|
registerWindsurfProject(workspaceRoot);
|
||||||
|
console.log(` Registered for auto-context updates`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall Windsurf hooks — removes claude-mem entries from hooks.json
|
||||||
|
*/
|
||||||
|
export function uninstallWindsurfHooks(): number {
|
||||||
|
console.log('\nUninstalling Claude-Mem Windsurf hooks...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove our entries from hooks.json (preserve other integrations)
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
try {
|
||||||
|
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
|
||||||
|
for (const eventName of WINDSURF_HOOK_EVENTS) {
|
||||||
|
if (config.hooks[eventName]) {
|
||||||
|
config.hooks[eventName] = config.hooks[eventName].filter(
|
||||||
|
(hook) => !hook.command.includes('worker-service') || !hook.command.includes('windsurf')
|
||||||
|
);
|
||||||
|
// Remove empty arrays
|
||||||
|
if (config.hooks[eventName].length === 0) {
|
||||||
|
delete config.hooks[eventName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no hooks remain, remove the file entirely
|
||||||
|
if (Object.keys(config.hooks).length === 0) {
|
||||||
|
unlinkSync(WINDSURF_HOOKS_JSON_PATH);
|
||||||
|
console.log(` Removed hooks.json (no hooks remaining)`);
|
||||||
|
} else {
|
||||||
|
writeFileSync(WINDSURF_HOOKS_JSON_PATH, JSON.stringify(config, null, 2));
|
||||||
|
console.log(` Removed claude-mem entries from hooks.json (other hooks preserved)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` Warning: could not parse hooks.json — leaving file intact to preserve other hooks`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` No hooks.json found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove context file from the current workspace
|
||||||
|
const workspaceRoot = process.cwd();
|
||||||
|
const contextFile = path.join(workspaceRoot, '.windsurf', 'rules', 'claude-mem-context.md');
|
||||||
|
if (existsSync(contextFile)) {
|
||||||
|
unlinkSync(contextFile);
|
||||||
|
console.log(` Removed context file`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister project
|
||||||
|
unregisterWindsurfProject(workspaceRoot);
|
||||||
|
console.log(` Unregistered from auto-context updates`);
|
||||||
|
|
||||||
|
console.log(`\nUninstallation complete!\n`);
|
||||||
|
console.log('Restart Windsurf to apply changes.');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Windsurf hooks installation status
|
||||||
|
*/
|
||||||
|
export function checkWindsurfHooksStatus(): number {
|
||||||
|
console.log('\nClaude-Mem Windsurf Hooks Status\n');
|
||||||
|
|
||||||
|
if (existsSync(WINDSURF_HOOKS_JSON_PATH)) {
|
||||||
|
console.log(`User-level: Installed`);
|
||||||
|
console.log(` Config: ${WINDSURF_HOOKS_JSON_PATH}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config: WindsurfHooksJson = JSON.parse(readFileSync(WINDSURF_HOOKS_JSON_PATH, 'utf-8'));
|
||||||
|
const registeredEvents = WINDSURF_HOOK_EVENTS.filter(
|
||||||
|
(event) => config.hooks[event]?.some(
|
||||||
|
(hook) => hook.command.includes('worker-service') && hook.command.includes('windsurf')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log(` Events: ${registeredEvents.length}/${WINDSURF_HOOK_EVENTS.length} registered`);
|
||||||
|
for (const event of registeredEvents) {
|
||||||
|
console.log(` - ${event}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(` Mode: Unable to parse hooks.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for context file in current workspace
|
||||||
|
const contextFile = path.join(process.cwd(), '.windsurf', 'rules', 'claude-mem-context.md');
|
||||||
|
if (existsSync(contextFile)) {
|
||||||
|
console.log(` Context: Active (current workspace)`);
|
||||||
|
} else {
|
||||||
|
console.log(` Context: Not yet generated for this workspace`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`User-level: Not installed`);
|
||||||
|
console.log(`\nNo hooks installed. Run: claude-mem windsurf install\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle windsurf subcommand for hooks installation
|
||||||
|
*/
|
||||||
|
export async function handleWindsurfCommand(subcommand: string, _args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'install':
|
||||||
|
return installWindsurfHooks();
|
||||||
|
|
||||||
|
case 'uninstall':
|
||||||
|
return uninstallWindsurfHooks();
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return checkWindsurfHooksStatus();
|
||||||
|
|
||||||
|
default: {
|
||||||
|
console.log(`
|
||||||
|
Claude-Mem Windsurf Integration
|
||||||
|
|
||||||
|
Usage: claude-mem windsurf <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install Windsurf hooks (user-level, ~/.codeium/windsurf/hooks.json)
|
||||||
|
uninstall Remove Windsurf hooks
|
||||||
|
status Check installation status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
claude-mem windsurf install # Install hooks globally
|
||||||
|
claude-mem windsurf uninstall # Remove hooks
|
||||||
|
claude-mem windsurf status # Check if hooks are installed
|
||||||
|
|
||||||
|
For more info: https://docs.claude-mem.ai/windsurf
|
||||||
|
`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Integrations module - IDE integrations (Cursor, etc.)
|
* Integrations module - IDE integrations (Cursor, Gemini CLI, OpenCode, Windsurf, etc.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './CursorHooksInstaller.js';
|
export * from './CursorHooksInstaller.js';
|
||||||
|
export * from './GeminiCliHooksInstaller.js';
|
||||||
|
export * from './OpenCodeInstaller.js';
|
||||||
|
export * from './WindsurfHooksInstaller.js';
|
||||||
|
export * from './OpenClawInstaller.js';
|
||||||
|
export * from './CodexCliInstaller.js';
|
||||||
|
export * from './McpIntegrations.js';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-wat
|
|||||||
|
|
||||||
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||||
name: 'codex',
|
name: 'codex',
|
||||||
version: '0.2',
|
version: '0.3',
|
||||||
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -46,13 +46,14 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-use',
|
name: 'tool-use',
|
||||||
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
|
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call', 'exec_command'] },
|
||||||
action: 'tool_use',
|
action: 'tool_use',
|
||||||
fields: {
|
fields: {
|
||||||
toolId: 'payload.call_id',
|
toolId: 'payload.call_id',
|
||||||
toolName: {
|
toolName: {
|
||||||
coalesce: [
|
coalesce: [
|
||||||
'payload.name',
|
'payload.name',
|
||||||
|
'payload.type',
|
||||||
{ value: 'web_search' }
|
{ value: 'web_search' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -60,6 +61,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
coalesce: [
|
coalesce: [
|
||||||
'payload.arguments',
|
'payload.arguments',
|
||||||
'payload.input',
|
'payload.input',
|
||||||
|
'payload.command',
|
||||||
'payload.action'
|
'payload.action'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-result',
|
name: 'tool-result',
|
||||||
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
|
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output', 'exec_command_output'] },
|
||||||
action: 'tool_result',
|
action: 'tool_result',
|
||||||
fields: {
|
fields: {
|
||||||
toolId: 'payload.call_id',
|
toolId: 'payload.call_id',
|
||||||
@@ -76,7 +78,7 @@ const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'session-end',
|
name: 'session-end',
|
||||||
match: { path: 'payload.type', equals: 'turn_aborted' },
|
match: { path: 'payload.type', in: ['turn_aborted', 'turn_completed'] },
|
||||||
action: 'session_end'
|
action: 'session_end'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ import {
|
|||||||
updateCursorContextForProject,
|
updateCursorContextForProject,
|
||||||
handleCursorCommand
|
handleCursorCommand
|
||||||
} from './integrations/CursorHooksInstaller.js';
|
} from './integrations/CursorHooksInstaller.js';
|
||||||
|
import {
|
||||||
|
handleGeminiCliCommand
|
||||||
|
} from './integrations/GeminiCliHooksInstaller.js';
|
||||||
|
|
||||||
// Service layer imports
|
// Service layer imports
|
||||||
import { DatabaseManager } from './worker/DatabaseManager.js';
|
import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||||
@@ -128,6 +131,10 @@ import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
|
|||||||
// Process management for zombie cleanup (Issue #737)
|
// Process management for zombie cleanup (Issue #737)
|
||||||
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI session monitoring
|
||||||
|
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||||
|
import { loadTranscriptWatchConfig, expandHomePath, DEFAULT_CONFIG_PATH as TRANSCRIPT_CONFIG_PATH } from './transcripts/config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build JSON status output for hook framework communication.
|
* Build JSON status output for hook framework communication.
|
||||||
* This is a pure function extracted for testability.
|
* This is a pure function extracted for testability.
|
||||||
@@ -189,6 +196,9 @@ export class WorkerService {
|
|||||||
// Stale session reaper interval (Issue #1168)
|
// Stale session reaper interval (Issue #1168)
|
||||||
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI sessions (e.g. Codex, Gemini)
|
||||||
|
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||||
|
|
||||||
// AI interaction tracking for health endpoint
|
// AI interaction tracking for health endpoint
|
||||||
private lastAiInteraction: {
|
private lastAiInteraction: {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -421,6 +431,22 @@ export class WorkerService {
|
|||||||
this.resolveInitialization();
|
this.resolveInitialization();
|
||||||
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
||||||
|
|
||||||
|
// Auto-start transcript watchers if configured
|
||||||
|
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
|
||||||
|
try {
|
||||||
|
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
|
||||||
|
if (transcriptConfig.watches.length > 0) {
|
||||||
|
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
|
||||||
|
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
|
||||||
|
await this.transcriptWatcher.start();
|
||||||
|
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
|
||||||
|
}
|
||||||
|
} catch (transcriptError) {
|
||||||
|
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
|
||||||
|
// Non-fatal — worker continues without transcript watching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
||||||
if (this.chromaMcpManager) {
|
if (this.chromaMcpManager) {
|
||||||
ChromaSync.backfillAllProjects().then(() => {
|
ChromaSync.backfillAllProjects().then(() => {
|
||||||
@@ -922,6 +948,13 @@ export class WorkerService {
|
|||||||
this.staleSessionReaperInterval = null;
|
this.staleSessionReaperInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop transcript watcher
|
||||||
|
if (this.transcriptWatcher) {
|
||||||
|
this.transcriptWatcher.stop();
|
||||||
|
this.transcriptWatcher = null;
|
||||||
|
logger.info('SYSTEM', 'Transcript watcher stopped');
|
||||||
|
}
|
||||||
|
|
||||||
await performGracefulShutdown({
|
await performGracefulShutdown({
|
||||||
server: this.server.getHttpServer(),
|
server: this.server.getHttpServer(),
|
||||||
sessionManager: this.sessionManager,
|
sessionManager: this.sessionManager,
|
||||||
@@ -1174,14 +1207,21 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'gemini-cli': {
|
||||||
|
const geminiSubcommand = process.argv[3];
|
||||||
|
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
|
||||||
|
process.exit(geminiResult);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'hook': {
|
case 'hook': {
|
||||||
// Validate CLI args first (before any I/O)
|
// Validate CLI args first (before any I/O)
|
||||||
const platform = process.argv[3];
|
const platform = process.argv[3];
|
||||||
const event = process.argv[4];
|
const event = process.argv[4];
|
||||||
if (!platform || !event) {
|
if (!platform || !event) {
|
||||||
console.error('Usage: claude-mem hook <platform> <event>');
|
console.error('Usage: claude-mem hook <platform> <event>');
|
||||||
console.error('Platforms: claude-code, cursor, raw');
|
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
|
||||||
console.error('Events: context, session-init, observation, summarize, session-complete');
|
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
||||||
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
||||||
|
app.get('/api/sessions/status', this.handleStatusByClaudeId.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -631,6 +632,39 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
res.json({ status: 'queued' });
|
res.json({ status: 'queued' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session status by contentSessionId (summarize handler polls this)
|
||||||
|
* GET /api/sessions/status?contentSessionId=...
|
||||||
|
*
|
||||||
|
* Returns queue depth so the Stop hook can wait for summary completion.
|
||||||
|
*/
|
||||||
|
private handleStatusByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||||
|
const contentSessionId = req.query.contentSessionId as string;
|
||||||
|
|
||||||
|
if (!contentSessionId) {
|
||||||
|
return this.badRequest(res, 'Missing contentSessionId query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = this.dbManager.getSessionStore();
|
||||||
|
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||||
|
const session = this.sessionManager.getSession(sessionDbId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.json({ status: 'not_found', queueLength: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||||
|
const queueLength = pendingStore.getPendingCount(sessionDbId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'active',
|
||||||
|
sessionDbId,
|
||||||
|
queueLength,
|
||||||
|
uptime: Date.now() - session.startTime
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete session by contentSessionId (session-complete hook uses this)
|
* Complete session by contentSessionId (session-complete hook uses this)
|
||||||
* POST /api/sessions/complete
|
* POST /api/sessions/complete
|
||||||
@@ -669,6 +703,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Complete the session (removes from active sessions map)
|
// Complete the session (removes from active sessions map)
|
||||||
|
// Note: The Stop hook (summarize handler) waits for pending work before calling
|
||||||
|
// this endpoint. No polling here — that's the hook's responsibility.
|
||||||
await this.completionHandler.completeByDbId(sessionDbId);
|
await this.completionHandler.completeByDbId(sessionDbId);
|
||||||
|
|
||||||
logger.info('SESSION', 'Session completed via API', {
|
logger.info('SESSION', 'Session completed via API', {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Shared context injection utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides tag constants and a function to inject or update a
|
||||||
|
* <claude-mem-context> section in any markdown file. Used by
|
||||||
|
* MCP integrations and OpenCode installer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tag Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CONTEXT_TAG_OPEN = '<claude-mem-context>';
|
||||||
|
export const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update a <claude-mem-context> section in a markdown file.
|
||||||
|
* Creates the file if it doesn't exist. Preserves content outside the tags.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the target markdown file.
|
||||||
|
* @param contextContent - The content to place between the context tags.
|
||||||
|
* @param headerLine - Optional first line written when creating a new file
|
||||||
|
* (e.g. `"# Claude-Mem Memory Context"` for AGENTS.md).
|
||||||
|
*/
|
||||||
|
export function injectContextIntoMarkdownFile(
|
||||||
|
filePath: string,
|
||||||
|
contextContent: string,
|
||||||
|
headerLine?: string,
|
||||||
|
): void {
|
||||||
|
const parentDirectory = path.dirname(filePath);
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
let existingContent = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
|
||||||
|
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
|
||||||
|
|
||||||
|
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
||||||
|
// Replace existing section
|
||||||
|
existingContent =
|
||||||
|
existingContent.slice(0, tagStartIndex) +
|
||||||
|
wrappedContent +
|
||||||
|
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
|
||||||
|
} else {
|
||||||
|
// Append section
|
||||||
|
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(filePath, existingContent, 'utf-8');
|
||||||
|
} else {
|
||||||
|
// Create new file
|
||||||
|
if (headerLine) {
|
||||||
|
writeFileSync(filePath, `${headerLine}\n\n${wrappedContent}\n`, 'utf-8');
|
||||||
|
} else {
|
||||||
|
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Shared JSON file utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides safe read/write helpers used across the CLI and services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a JSON file safely, returning a default value if the file
|
||||||
|
* does not exist. Throws on corrupt JSON to prevent silent data loss
|
||||||
|
* when callers merge and write back.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the JSON file.
|
||||||
|
* @param defaultValue - Value returned when the file is missing.
|
||||||
|
* @returns The parsed JSON content, or `defaultValue` when file is missing.
|
||||||
|
* @throws {Error} When the file exists but contains invalid JSON.
|
||||||
|
*/
|
||||||
|
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
|
||||||
|
if (!existsSync(filePath)) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Corrupt JSON file, refusing to overwrite: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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 {
|
||||||
|
injectContextIntoMarkdownFile,
|
||||||
|
CONTEXT_TAG_OPEN,
|
||||||
|
CONTEXT_TAG_CLOSE,
|
||||||
|
} from '../src/utils/context-injection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the shared context injection utility.
|
||||||
|
*
|
||||||
|
* injectContextIntoMarkdownFile is used by MCP integrations and OpenCode
|
||||||
|
* installer to inject or update a <claude-mem-context> section in markdown files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('Context Injection', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `context-injection-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tag constants', () => {
|
||||||
|
it('exports correct open and close tags', () => {
|
||||||
|
expect(CONTEXT_TAG_OPEN).toBe('<claude-mem-context>');
|
||||||
|
expect(CONTEXT_TAG_CLOSE).toBe('</claude-mem-context>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inject into new file', () => {
|
||||||
|
it('creates a new file with context tags when file does not exist', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'Hello world');
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('Hello world');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_CLOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directories if they do not exist', () => {
|
||||||
|
const filePath = join(tempDir, 'nested', 'deep', 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'test content');
|
||||||
|
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes content wrapped in context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const contextContent = '# Recent Activity\n\nSome memory data here.';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, contextContent);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const expected = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}\n`;
|
||||||
|
expect(content).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headerLine support', () => {
|
||||||
|
it('prepends headerLine when creating a new file', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
const headerLine = '# Claude-Mem Memory Context';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'context data', headerLine);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content.startsWith(headerLine)).toBe(true);
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('context data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a blank line between headerLine and context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
const headerLine = '# My Header';
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data', headerLine);
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toBe(`${headerLine}\n\n${CONTEXT_TAG_OPEN}\ndata\n${CONTEXT_TAG_CLOSE}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use headerLine when file already exists', () => {
|
||||||
|
const filePath = join(tempDir, 'AGENTS.md');
|
||||||
|
writeFileSync(filePath, '# Existing Content\n\nSome stuff.\n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'new context', '# Should Not Appear');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# Existing Content');
|
||||||
|
expect(content).not.toContain('# Should Not Appear');
|
||||||
|
expect(content).toContain('new context');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replace existing context section', () => {
|
||||||
|
it('replaces content between existing context tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const initialContent = [
|
||||||
|
'# Project Instructions',
|
||||||
|
'',
|
||||||
|
`${CONTEXT_TAG_OPEN}`,
|
||||||
|
'Old context data',
|
||||||
|
`${CONTEXT_TAG_CLOSE}`,
|
||||||
|
'',
|
||||||
|
'## Other stuff',
|
||||||
|
].join('\n');
|
||||||
|
writeFileSync(filePath, initialContent);
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'New context data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('New context data');
|
||||||
|
expect(content).not.toContain('Old context data');
|
||||||
|
expect(content).toContain('# Project Instructions');
|
||||||
|
expect(content).toContain('## Other stuff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves content before and after the context section', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
const before = '# Header\n\nSome instructions.\n\n';
|
||||||
|
const after = '\n\n## Footer\n\nMore content.\n';
|
||||||
|
const initialContent = `${before}${CONTEXT_TAG_OPEN}\nold\n${CONTEXT_TAG_CLOSE}${after}`;
|
||||||
|
writeFileSync(filePath, initialContent);
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'replaced');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# Header');
|
||||||
|
expect(content).toContain('Some instructions.');
|
||||||
|
expect(content).toContain('## Footer');
|
||||||
|
expect(content).toContain('More content.');
|
||||||
|
expect(content).toContain('replaced');
|
||||||
|
expect(content).not.toContain('old');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('append to existing file', () => {
|
||||||
|
it('appends context section to file without existing tags', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# My Project\n\nInstructions here.\n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'appended context');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('# My Project');
|
||||||
|
expect(content).toContain('Instructions here.');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_OPEN);
|
||||||
|
expect(content).toContain('appended context');
|
||||||
|
expect(content).toContain(CONTEXT_TAG_CLOSE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates appended section with a blank line', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# Header');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
// Should have double newline before the tag
|
||||||
|
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims trailing whitespace before appending', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
writeFileSync(filePath, '# Header\n\n\n \n');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'data');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
// Should not have excessive whitespace before the tag
|
||||||
|
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency', () => {
|
||||||
|
it('produces same result when called twice with same content', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'stable content');
|
||||||
|
const firstWrite = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'stable content');
|
||||||
|
const secondWrite = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
expect(secondWrite).toBe(firstWrite);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates content when called with different data', () => {
|
||||||
|
const filePath = join(tempDir, 'CLAUDE.md');
|
||||||
|
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'version 1');
|
||||||
|
injectContextIntoMarkdownFile(filePath, 'version 2');
|
||||||
|
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
expect(content).toContain('version 2');
|
||||||
|
expect(content).not.toContain('version 1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the non-TTY detection in the install command.
|
||||||
|
*
|
||||||
|
* The install command (src/npx-cli/commands/install.ts) has non-interactive
|
||||||
|
* fallbacks so it works in CI/CD, Docker, and piped environments where
|
||||||
|
* process.stdin.isTTY is undefined.
|
||||||
|
*
|
||||||
|
* Since isInteractive, runTasks, and log are not exported, we verify
|
||||||
|
* their presence and correctness via source inspection. This is a valid
|
||||||
|
* approach for testing private module-level constructs that can't be
|
||||||
|
* imported directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const installSourcePath = join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'src',
|
||||||
|
'npx-cli',
|
||||||
|
'commands',
|
||||||
|
'install.ts',
|
||||||
|
);
|
||||||
|
const installSource = readFileSync(installSourcePath, 'utf-8');
|
||||||
|
|
||||||
|
describe('Install Non-TTY Support', () => {
|
||||||
|
describe('isInteractive flag', () => {
|
||||||
|
it('defines isInteractive based on process.stdin.isTTY', () => {
|
||||||
|
expect(installSource).toContain('const isInteractive = process.stdin.isTTY === true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses strict equality (===) not truthy check for isTTY', () => {
|
||||||
|
// Ensures undefined isTTY is treated as false, not just falsy
|
||||||
|
const match = installSource.match(/const isInteractive = process\.stdin\.isTTY === true/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runTasks helper', () => {
|
||||||
|
it('defines a runTasks function', () => {
|
||||||
|
expect(installSource).toContain('async function runTasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has interactive branch using p.tasks', () => {
|
||||||
|
expect(installSource).toContain('await p.tasks(tasks)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has non-interactive fallback using console.log', () => {
|
||||||
|
// In non-TTY mode, tasks iterate and log output directly
|
||||||
|
expect(installSource).toContain('console.log(` ${msg}`)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('branches on isInteractive', () => {
|
||||||
|
expect(installSource).toContain('if (isInteractive)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('log wrapper', () => {
|
||||||
|
it('defines log.info that falls back to console.log', () => {
|
||||||
|
expect(installSource).toContain('info: (msg: string) =>');
|
||||||
|
// Should have console.log fallback
|
||||||
|
expect(installSource).toMatch(/info:.*console\.log/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.success that falls back to console.log', () => {
|
||||||
|
expect(installSource).toContain('success: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/success:.*console\.log/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.warn that falls back to console.warn', () => {
|
||||||
|
expect(installSource).toContain('warn: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/warn:.*console\.warn/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines log.error that falls back to console.error', () => {
|
||||||
|
expect(installSource).toContain('error: (msg: string) =>');
|
||||||
|
expect(installSource).toMatch(/error:.*console\.error/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-interactive install path', () => {
|
||||||
|
it('defaults to claude-code when not interactive and no IDE specified', () => {
|
||||||
|
// The non-interactive path should have a fallback
|
||||||
|
expect(installSource).toContain("selectedIDEs = ['claude-code']");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses console.log for intro in non-interactive mode', () => {
|
||||||
|
expect(installSource).toContain("console.log('claude-mem install')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses console.log for note/summary in non-interactive mode', () => {
|
||||||
|
expect(installSource).toContain("console.log(`\\n ${installStatus}`)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskDescriptor interface', () => {
|
||||||
|
it('defines a task interface with title and task function', () => {
|
||||||
|
expect(installSource).toContain('interface TaskDescriptor');
|
||||||
|
expect(installSource).toContain('title: string');
|
||||||
|
expect(installSource).toContain('task: (message: (msg: string) => void) => Promise<string>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InstallOptions interface', () => {
|
||||||
|
it('exports InstallOptions with optional ide field', () => {
|
||||||
|
expect(installSource).toContain('export interface InstallOptions');
|
||||||
|
expect(installSource).toContain('ide?: string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 { readJsonSafe } from '../src/utils/json-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the shared JSON file utilities.
|
||||||
|
*
|
||||||
|
* readJsonSafe is used across the CLI and services to safely read JSON
|
||||||
|
* files with fallback to defaults when files are missing or corrupt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('JSON Utils', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `json-utils-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readJsonSafe', () => {
|
||||||
|
it('returns default value when file does not exist', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'does-not-exist.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe(nonExistentPath, { fallback: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ fallback: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed content for valid JSON file', () => {
|
||||||
|
const filePath = join(tempDir, 'valid.json');
|
||||||
|
const data = { name: 'test', count: 42, nested: { key: 'value' } };
|
||||||
|
writeFileSync(filePath, JSON.stringify(data));
|
||||||
|
|
||||||
|
const result = readJsonSafe(filePath, {});
|
||||||
|
|
||||||
|
expect(result).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on corrupt JSON file to prevent data loss', () => {
|
||||||
|
const filePath = join(tempDir, 'corrupt.json');
|
||||||
|
writeFileSync(filePath, 'this is not valid json {{{');
|
||||||
|
|
||||||
|
expect(() => readJsonSafe(filePath, { recovered: true })).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on empty file to prevent data loss', () => {
|
||||||
|
const filePath = join(tempDir, 'empty.json');
|
||||||
|
writeFileSync(filePath, '');
|
||||||
|
|
||||||
|
expect(() => readJsonSafe(filePath, [])).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with array default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<string[]>(nonExistentPath, ['a', 'b']);
|
||||||
|
|
||||||
|
expect(result).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with string default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<string>(nonExistentPath, 'default');
|
||||||
|
|
||||||
|
expect(result).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with number default values', () => {
|
||||||
|
const nonExistentPath = join(tempDir, 'missing.json');
|
||||||
|
|
||||||
|
const result = readJsonSafe<number>(nonExistentPath, 0);
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads JSON arrays correctly', () => {
|
||||||
|
const filePath = join(tempDir, 'array.json');
|
||||||
|
writeFileSync(filePath, JSON.stringify([1, 2, 3]));
|
||||||
|
|
||||||
|
const result = readJsonSafe<number[]>(filePath, []);
|
||||||
|
|
||||||
|
expect(result).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads deeply nested JSON correctly', () => {
|
||||||
|
const filePath = join(tempDir, 'nested.json');
|
||||||
|
const deepData = {
|
||||||
|
level1: {
|
||||||
|
level2: {
|
||||||
|
level3: {
|
||||||
|
value: 'deep',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(filePath, JSON.stringify(deepData));
|
||||||
|
|
||||||
|
const result = readJsonSafe<typeof deepData>(filePath, { level1: { level2: { level3: { value: '' } } } });
|
||||||
|
|
||||||
|
expect(result.level1.level2.level3.value).toBe('deep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JSON with trailing newline', () => {
|
||||||
|
const filePath = join(tempDir, 'trailing-newline.json');
|
||||||
|
writeFileSync(filePath, JSON.stringify({ ok: true }) + '\n');
|
||||||
|
|
||||||
|
const result = readJsonSafe(filePath, {});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the MCP integration factory utilities.
|
||||||
|
*
|
||||||
|
* Because McpIntegrations.ts uses `findMcpServerPath()` which checks specific
|
||||||
|
* filesystem paths, and the factory functions are not individually exported,
|
||||||
|
* we test the underlying helpers indirectly by exercising writeMcpJsonConfig
|
||||||
|
* and buildMcpServerEntry behavior through the readJsonSafe + JSON file writing
|
||||||
|
* patterns they use.
|
||||||
|
*
|
||||||
|
* We also verify the key behavioral contract: MCP entries use process.execPath.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readJsonSafe } from '../src/utils/json-utils';
|
||||||
|
import { injectContextIntoMarkdownFile, CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE } from '../src/utils/context-injection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reimplements the core logic of buildMcpServerEntry and writeMcpJsonConfig
|
||||||
|
* from McpIntegrations.ts for testability, since those functions are not exported.
|
||||||
|
* The tests verify the contract these functions must uphold.
|
||||||
|
*/
|
||||||
|
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [mcpServerPath],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMcpJsonConfig(
|
||||||
|
configFilePath: string,
|
||||||
|
mcpServerPath: string,
|
||||||
|
serversKeyName: string = 'mcpServers',
|
||||||
|
): void {
|
||||||
|
const parentDirectory = join(configFilePath, '..');
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const existingConfig = readJsonSafe<Record<string, any>>(configFilePath, {});
|
||||||
|
|
||||||
|
if (!existingConfig[serversKeyName]) {
|
||||||
|
existingConfig[serversKeyName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
existingConfig[serversKeyName]['claude-mem'] = buildMcpServerEntry(mcpServerPath);
|
||||||
|
|
||||||
|
writeFileSync(configFilePath, JSON.stringify(existingConfig, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCP Integrations', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(tmpdir(), `mcp-integrations-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildMcpServerEntry', () => {
|
||||||
|
it('uses process.execPath as the command, not "node"', () => {
|
||||||
|
const entry = buildMcpServerEntry('/path/to/mcp-server.cjs');
|
||||||
|
|
||||||
|
expect(entry.command).toBe(process.execPath);
|
||||||
|
expect(entry.command).not.toBe('node');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the mcp server path as the sole argument', () => {
|
||||||
|
const serverPath = '/usr/local/lib/mcp-server.cjs';
|
||||||
|
const entry = buildMcpServerEntry(serverPath);
|
||||||
|
|
||||||
|
expect(entry.args).toEqual([serverPath]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paths with spaces', () => {
|
||||||
|
const serverPath = '/path/to/my project/mcp-server.cjs';
|
||||||
|
const entry = buildMcpServerEntry(serverPath);
|
||||||
|
|
||||||
|
expect(entry.args).toEqual([serverPath]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeMcpJsonConfig', () => {
|
||||||
|
it('creates config file if it does not exist', () => {
|
||||||
|
const configPath = join(tempDir, '.config', 'ide', 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
expect(existsSync(configPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates parent directories if they do not exist', () => {
|
||||||
|
const configPath = join(tempDir, 'deep', 'nested', 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
expect(existsSync(join(tempDir, 'deep', 'nested'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes valid JSON with claude-mem entry', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
expect(config.mcpServers).toBeDefined();
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
expect(config.mcpServers['claude-mem'].command).toBe(process.execPath);
|
||||||
|
expect(config.mcpServers['claude-mem'].args).toEqual(['/path/to/mcp.cjs']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom serversKeyName when provided', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs', 'servers');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.servers).toBeDefined();
|
||||||
|
expect(config.servers['claude-mem']).toBeDefined();
|
||||||
|
expect(config.mcpServers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves existing servers when adding claude-mem', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
'other-tool': {
|
||||||
|
command: 'python',
|
||||||
|
args: ['/path/to/other.py'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['other-tool']).toBeDefined();
|
||||||
|
expect(config.mcpServers['other-tool'].command).toBe('python');
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves non-server keys in existing config', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
version: 2,
|
||||||
|
settings: { theme: 'dark' },
|
||||||
|
mcpServers: {},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.version).toBe(2);
|
||||||
|
expect(config.settings).toEqual({ theme: 'dark' });
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('idempotency', () => {
|
||||||
|
it('running install twice does not create duplicate entries', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
const serverKeys = Object.keys(config.mcpServers);
|
||||||
|
const claudeMemEntries = serverKeys.filter((k) => k === 'claude-mem');
|
||||||
|
expect(claudeMemEntries).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the server path on re-install', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/old/path/mcp.cjs');
|
||||||
|
writeMcpJsonConfig(configPath, '/new/path/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['claude-mem'].args).toEqual(['/new/path/mcp.cjs']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('corrupt file recovery', () => {
|
||||||
|
it('throws on corrupt JSON to prevent data loss', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, 'not valid json {{{{');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
|
||||||
|
// Original file should be untouched
|
||||||
|
expect(readFileSync(configPath, 'utf-8')).toBe('not valid json {{{{');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on empty file to prevent data loss', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, '');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on file with only whitespace', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, ' \n\n ');
|
||||||
|
|
||||||
|
expect(() => writeMcpJsonConfig(configPath, '/path/to/mcp.cjs')).toThrow(
|
||||||
|
/Corrupt JSON file, refusing to overwrite/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('merge with existing config', () => {
|
||||||
|
it('preserves other servers in mcpServers key', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
'server-a': { command: 'ruby', args: ['/a.rb'] },
|
||||||
|
'server-b': { command: 'node', args: ['/b.js'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(Object.keys(config.mcpServers)).toHaveLength(3);
|
||||||
|
expect(config.mcpServers['server-a'].command).toBe('ruby');
|
||||||
|
expect(config.mcpServers['server-b'].command).toBe('node');
|
||||||
|
expect(config.mcpServers['claude-mem'].command).toBe(process.execPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves other servers when using "servers" key', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
const existingConfig = {
|
||||||
|
servers: {
|
||||||
|
'copilot-tool': { command: 'python', args: ['/tool.py'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
writeFileSync(configPath, JSON.stringify(existingConfig));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs', 'servers');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.servers['copilot-tool']).toBeDefined();
|
||||||
|
expect(config.servers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles config with mcpServers as empty object', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles config without the servers key at all', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
writeFileSync(configPath, JSON.stringify({ version: 1 }));
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||||
|
expect(config.version).toBe(1);
|
||||||
|
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('output format', () => {
|
||||||
|
it('writes pretty-printed JSON with 2-space indent', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
expect(content).toContain('\n');
|
||||||
|
expect(content).toContain(' "mcpServers"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends file with trailing newline', () => {
|
||||||
|
const configPath = join(tempDir, 'mcp.json');
|
||||||
|
|
||||||
|
writeMcpJsonConfig(configPath, '/path/to/mcp.cjs');
|
||||||
|
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
expect(content.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user