From e975555896b5d5c5066197d64d130d724e7ca4db Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Mon, 16 Feb 2026 00:44:21 -0500 Subject: [PATCH] feat: add interactive CLI installer with @clack/prompts (#1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Switch to persistent Chroma HTTP server Replace MCP subprocess approach with persistent Chroma HTTP server for improved performance and reliability. This re-enables Chroma on Windows by eliminating the subprocess spawning that caused console popups. Changes: - NEW: ChromaServerManager.ts - Manages local Chroma server lifecycle via `npx chroma run` - REFACTOR: ChromaSync.ts - Uses chromadb npm package's ChromaClient instead of MCP subprocess (removes Windows disabling) - UPDATE: worker-service.ts - Starts Chroma server on initialization - UPDATE: GracefulShutdown.ts - Stops Chroma server on shutdown - UPDATE: SettingsDefaultsManager.ts - New Chroma configuration options - UPDATE: build-hooks.js - Mark optional chromadb deps as external Benefits: - Eliminates subprocess spawn latency on first query - Single server process instead of per-operation subprocesses - No Python/uvx dependency for local mode - Re-enables Chroma vector search on Windows - Future-ready for cloud-hosted Chroma (claude-mem pro) - Cross-platform: Linux, macOS, Windows Configuration: CLAUDE_MEM_CHROMA_MODE=local|remote CLAUDE_MEM_CHROMA_HOST=127.0.0.1 CLAUDE_MEM_CHROMA_PORT=8000 CLAUDE_MEM_CHROMA_SSL=false Co-Authored-By: Claude Opus 4.5 * fix: Use chromadb v3.2.2 with v2 API heartbeat endpoint - Updated chromadb from ^1.9.2 to ^3.2.2 (includes CLI binary) - Changed heartbeat endpoint from /api/v1 to /api/v2 The 1.9.x version did not include the CLI, causing `npx chroma run` to fail. Version 3.2.2 includes the chroma CLI and uses the v2 API. Co-Authored-By: Claude Opus 4.5 * feat: Add DefaultEmbeddingFunction for local vector embeddings - Added @chroma-core/default-embed dependency for local embeddings - Updated ChromaSync to use DefaultEmbeddingFunction with collections - Added isServerReachable() async method for reliable server detection - Fixed start() to detect and reuse existing Chroma servers - Updated build script to externalize native ONNX binaries - Added runtime dependency to plugin/package.json The embedding function uses all-MiniLM-L6-v2 model locally via ONNX, eliminating need for external embedding API calls. Co-Authored-By: Claude Opus 4.5 * Update src/services/sync/ChromaServerManager.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: Remove duplicate else block from merge Co-Authored-By: Claude Opus 4.5 * feat: Add multi-tenancy support for claude-mem pro Wire tenant, database, and API key settings into ChromaSync for remote/pro mode. In remote mode: - Passes tenant and database to ChromaClient for data isolation - Adds Authorization header when API key is configured - Logs tenant isolation connection details Local mode unchanged - uses default_tenant without explicit params. Co-Authored-By: Claude Opus 4.5 * fix: add plugin.json to root .claude-plugin directory Claude Code's plugin discovery looks for plugin.json at the marketplace root level in .claude-plugin/, not nested inside plugin/.claude-plugin/. Without this file at the root level, skills and commands are not discovered. This matches the structure of working plugins like claude-research-team. Co-Authored-By: Claude Opus 4.5 * fix: resolve SDK spawn failures and sharp native binary crashes - Strip CLAUDECODE env var from SDK subprocesses to prevent "cannot be launched inside another Claude Code session" error (Claude Code 2.1.42+) - Lazy-load @chroma-core/default-embed to avoid eagerly pulling in sharp native binaries at bundle startup (fixes ERR_DLOPEN_FAILED) - Add stderr capture to SDK spawn for diagnosing future process failures - Exclude lockfiles from marketplace rsync and delete stale lockfiles before npm install to prevent native dep version mismatches Co-Authored-By: Claude Opus 4.6 * feat: scaffold installer package with @clack/prompts and esbuild Sets up the claude-mem-installer project structure with build tooling, placeholder step and utility modules, and verified esbuild bundling. Co-Authored-By: Claude Opus 4.6 * feat: implement entry point, welcome screen, and dependency checks Adds TTY guard, styled welcome banner with install mode selection, OS detection utilities, and automated dependency checking/installation for Node.js, git, Bun, and uv. Co-Authored-By: Claude Opus 4.6 * feat: implement IDE selection and AI provider configuration Adds multiselect IDE picker (Claude Code, Cursor) and provider configuration with Claude CLI/API, Gemini, and OpenRouter support. Co-Authored-By: Claude Opus 4.6 * feat: implement settings configuration wizard and settings file writer Adds interactive settings wizard with default/custom modes, Chroma configuration, and a settings writer that merges with existing settings for upgrade support. Co-Authored-By: Claude Opus 4.6 * feat: implement installation execution and worker startup Adds git clone, build, plugin registration (marketplace, cache, settings), and worker startup with health check polling. Fixes TypeScript errors in settings.ts validate callbacks. Co-Authored-By: Claude Opus 4.6 * feat: add completion screen and curl|bash bootstrap script Completion screen shows configuration summary and next steps. Bootstrap shell script enables curl -fsSL install.cmem.ai | bash with TTY reconnection for interactive prompts. Co-Authored-By: Claude Opus 4.6 * feat: wire up full installer flow in index.ts Connects all steps: welcome → dependency checks → IDE selection → provider config → settings → installation → worker startup → completion. Configure-only mode skips clone/build/worker steps. Co-Authored-By: Claude Opus 4.6 * docs: add animated installer implementation plan Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: bigphoot Co-authored-by: Claude Opus 4.5 Co-authored-by: Alexander Knigge <166455923+bigph00t@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: bigphoot --- .claude/plans/animated-installer.md | 371 +++++++++++++++++++++++++ install/public/install.sh | 59 ++++ install/vercel.json | 7 + installer/build.mjs | 16 ++ installer/package.json | 21 ++ installer/src/index.ts | 49 ++++ installer/src/steps/complete.ts | 56 ++++ installer/src/steps/dependencies.ts | 168 +++++++++++ installer/src/steps/ide-selection.ts | 32 +++ installer/src/steps/install.ts | 167 +++++++++++ installer/src/steps/provider.ts | 140 ++++++++++ installer/src/steps/settings.ts | 174 ++++++++++++ installer/src/steps/welcome.ts | 43 +++ installer/src/steps/worker.ts | 67 +++++ installer/src/utils/dependencies.ts | 74 +++++ installer/src/utils/settings-writer.ts | 82 ++++++ installer/src/utils/system.ts | 49 ++++ installer/tsconfig.json | 17 ++ 18 files changed, 1592 insertions(+) create mode 100644 .claude/plans/animated-installer.md create mode 100644 install/public/install.sh create mode 100644 installer/build.mjs create mode 100644 installer/package.json create mode 100644 installer/src/index.ts create mode 100644 installer/src/steps/complete.ts create mode 100644 installer/src/steps/dependencies.ts create mode 100644 installer/src/steps/ide-selection.ts create mode 100644 installer/src/steps/install.ts create mode 100644 installer/src/steps/provider.ts create mode 100644 installer/src/steps/settings.ts create mode 100644 installer/src/steps/welcome.ts create mode 100644 installer/src/steps/worker.ts create mode 100644 installer/src/utils/dependencies.ts create mode 100644 installer/src/utils/settings-writer.ts create mode 100644 installer/src/utils/system.ts create mode 100644 installer/tsconfig.json diff --git a/.claude/plans/animated-installer.md b/.claude/plans/animated-installer.md new file mode 100644 index 00000000..04667134 --- /dev/null +++ b/.claude/plans/animated-installer.md @@ -0,0 +1,371 @@ +# Comprehensive Claude-Mem Installer with @clack/prompts + +## Overview + +Build a beautiful, animated CLI installer for claude-mem using `@clack/prompts` (v1.0.1). Distributable via `npx claude-mem-installer` and `curl -fsSL https://install.cmem.ai | bash`. Replaces the need for users to manually clone, build, configure settings, and start the worker. + +**Worktree**: `feat/animated-installer` at `.claude/worktrees/animated-installer` + +--- + +## Phase 0: Documentation & API Reference + +### Allowed APIs (@clack/prompts v1.0.1, ESM-only) + +| API | Signature | Use Case | +|-----|-----------|----------| +| `intro(title?)` | `void` | Opening banner | +| `outro(message?)` | `void` | Completion message | +| `cancel(message?)` | `void` | User cancelled | +| `isCancel(value)` | `boolean` | Check if user pressed Ctrl+C | +| `text(opts)` | `Promise` | API key input, port, data dir | +| `password(opts)` | `Promise` | API key input (masked) | +| `select(opts)` | `Promise` | Provider, model, auth method | +| `multiselect(opts)` | `Promise` | IDE selection, observation types | +| `confirm(opts)` | `Promise` | Enable Chroma, start worker | +| `spinner()` | `SpinnerResult` | Installing deps, building, starting worker | +| `progress(opts)` | `ProgressResult` | Multi-step installation progress | +| `tasks(tasks[])` | `Promise` | Sequential install steps | +| `group(prompts, opts)` | `Promise` | Chain prompts with shared results | +| `note(message, title)` | `void` | Display settings summary, next steps | +| `log.info/success/warn/error(msg)` | `void` | Status messages | +| `box(message, title, opts)` | `void` | Welcome box, completion summary | + +### Anti-Patterns +- Do NOT use `require()` — package is ESM-only +- Do NOT call prompts without TTY check first — hangs indefinitely in non-TTY +- Do NOT forget `isCancel()` check after every prompt (or use `group()` with `onCancel`) +- Do NOT use `chalk` — use `picocolors` (clack's dep) for consistency +- `text()` has no numeric mode — validate manually for port numbers +- `spinner.stop()` does not accept status codes — use `spinner.error()` for failures + +### Distribution Patterns +- **npx**: `package.json` `bin` field → `"./dist/index.js"`, file needs `#!/usr/bin/env node` +- **curl|bash**: Shell bootstrap downloads JS, runs `node script.js` directly (preserves TTY) +- **esbuild**: Bundle to single ESM file, `platform: 'node'`, `banner` for shebang + +### Key Source Files to Reference +- Settings defaults: `src/shared/SettingsDefaultsManager.ts` (lines 73-125) +- Settings validation: `src/services/server/SettingsRoutes.ts` +- Worker startup: `src/services/worker-service.ts` (lines 337-359) +- Health check: `src/services/infrastructure/HealthMonitor.ts` +- Plugin registration: `plugin/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` +- Marketplace sync: `scripts/sync-marketplace.cjs` +- Cursor integration: `src/services/integrations/CursorHooksInstaller.ts` +- Existing OpenClaw installer: `install/public/openclaw.sh` (reference for logic, not code to copy) + +--- + +## Phase 1: Project Scaffolding + +**Goal**: Set up the installer package structure with build tooling. + +### Tasks + +1. **Create directory structure** in the worktree: + ``` + installer/ + ├── src/ + │ ├── index.ts # Entry point with TTY guard + │ ├── steps/ + │ │ ├── welcome.ts # intro + version check + │ │ ├── dependencies.ts # bun, uv, git checks + │ │ ├── ide-selection.ts # IDE picker + registration + │ │ ├── provider.ts # AI provider + API key + │ │ ├── settings.ts # Additional settings config + │ │ ├── install.ts # Clone, build, register plugin + │ │ ├── worker.ts # Start worker + health check + │ │ └── complete.ts # Summary + next steps + │ └── utils/ + │ ├── system.ts # OS detection, command runner + │ ├── dependencies.ts # bun/uv/git install helpers + │ └── settings-writer.ts # Write ~/.claude-mem/settings.json + ├── build.mjs # esbuild config + ├── package.json # bin, type: module, deps + └── tsconfig.json + ``` + +2. **Create `package.json`**: + ```json + { + "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" } + } + ``` + +3. **Create `build.mjs`**: + - esbuild bundle: `entryPoints: ['src/index.ts']`, `format: 'esm'`, `platform: 'node'`, `target: 'node18'` + - Banner: `#!/usr/bin/env node` + - Output: `dist/index.js` + +4. **Create `tsconfig.json`**: + - `module: "ESNext"`, `target: "ES2022"`, `moduleResolution: "bundler"` + +5. **Run `npm install`** in installer/ directory + +### Verification +- [ ] `node build.mjs` succeeds +- [ ] `dist/index.js` exists with shebang +- [ ] `node dist/index.js` runs (even if empty installer) + +--- + +## Phase 2: Entry Point + Welcome Screen + +**Goal**: Create the main entry point with TTY detection and a beautiful welcome screen. + +### Tasks + +1. **`src/index.ts`** — Entry point: + - TTY guard: if `!process.stdin.isTTY`, print error directing user to `npx claude-mem-installer`, exit 1 + - Import and call `runInstaller()` from steps + - Top-level catch → `p.cancel()` + exit 1 + +2. **`src/steps/welcome.ts`** — Welcome step: + - `p.intro()` with styled title using picocolors: `" claude-mem installer "` + - Display version info via `p.log.info()` + - Check if already installed (detect `~/.claude-mem/settings.json` and `~/.claude/plugins/marketplaces/thedotmack/`) + - If upgrade detected, `p.confirm()`: "claude-mem is already installed. Upgrade?" + - `p.select()` for install mode: Fresh Install vs Upgrade vs Configure Only + +3. **`src/utils/system.ts`** — System utilities: + - `detectOS()`: returns 'macos' | 'linux' | 'windows' + - `commandExists(cmd)`: checks if command is in PATH + - `runCommand(cmd, args)`: executes shell command, returns { stdout, stderr, exitCode } + - `expandHome(path)`: resolves `~` to home directory + +### Verification +- [ ] Running `node dist/index.js` shows intro banner +- [ ] Ctrl+C triggers cancel message +- [ ] Non-TTY (piped) shows error and exits + +--- + +## Phase 3: Dependency Checks + +**Goal**: Check and install required dependencies (Bun, uv, git, Node.js version). + +### Tasks + +1. **`src/steps/dependencies.ts`** — Dependency checker: + - Use `p.tasks()` to check each dependency sequentially with animated spinners: + - **Node.js**: Verify >= 18.0.0 via `process.version` + - **git**: `commandExists('git')`, show install instructions per OS if missing + - **Bun**: Check PATH + common locations (`~/.bun/bin/bun`, `/usr/local/bin/bun`, `/opt/homebrew/bin/bun`). Min version 1.1.14. Offer to auto-install from `https://bun.sh/install` + - **uv**: Check PATH + common locations (`~/.local/bin/uv`, `~/.cargo/bin/uv`). Offer to auto-install from `https://astral.sh/uv/install.sh` + - For missing deps: `p.confirm()` to auto-install, or show manual instructions + - After install attempts, re-verify each dep + +2. **`src/utils/dependencies.ts`** — Install helpers: + - `installBun()`: downloads and runs bun install script + - `installUv()`: downloads and runs uv install script + - `findBinary(name, extraPaths[])`: searches PATH + known locations + - `checkVersion(binary, minVersion)`: parses `--version` output + +### Verification +- [ ] Shows green checkmarks for found dependencies +- [ ] Shows yellow warnings for missing deps with install option +- [ ] Auto-install actually installs bun/uv when confirmed +- [ ] Fails gracefully if git is missing (can't auto-install) + +--- + +## Phase 4: IDE Selection & Provider Configuration + +**Goal**: Let user choose IDEs and configure AI provider with API keys. + +### Tasks + +1. **`src/steps/ide-selection.ts`** — IDE picker: + - `p.multiselect()` with options: + - Claude Code (default selected, hint: "recommended") + - Cursor + - Windsurf (hint: "coming soon", disabled: true) + - For Claude Code: explain plugin will be registered via marketplace + - For Cursor: explain hooks will be installed via CursorHooksInstaller pattern + - Store selections for later installation steps + +2. **`src/steps/provider.ts`** — AI provider configuration: + - `p.select()` for provider: + - **Claude** (hint: "recommended — uses your Claude subscription") + - **Gemini** (hint: "free tier available") + - **OpenRouter** (hint: "free models available") + - **If Claude selected**: + - `p.select()` for auth method: "CLI (Max Plan subscription)" vs "API Key" + - If API key: `p.password()` for key input + - **If Gemini selected**: + - `p.password()` for API key (required) + - `p.select()` for model: gemini-2.5-flash-lite (default), gemini-2.5-flash, gemini-3-flash-preview + - `p.confirm()` for rate limiting (default: true) + - **If OpenRouter selected**: + - `p.password()` for API key (required) + - `p.text()` for model (default: `xiaomi/mimo-v2-flash:free`) + - Validate API keys where possible (non-empty, format check) + +### Verification +- [ ] Multiselect allows picking multiple IDEs +- [ ] Provider selection shows correct follow-up prompts +- [ ] API keys are masked during input +- [ ] Cancel at any step triggers graceful exit + +--- + +## Phase 5: Settings Configuration + +**Goal**: Configure additional settings with sensible defaults. + +### Tasks + +1. **`src/steps/settings.ts`** — Settings wizard: + - `p.confirm()`: "Use default settings?" (recommended) — if yes, skip detailed config + - If customizing, use `p.group()` for: + - **Worker port**: `p.text()` with default 37777, validate 1024-65535 + - **Data directory**: `p.text()` with default `~/.claude-mem` + - **Context observations**: `p.text()` with default 50, validate 1-200 + - **Log level**: `p.select()` — DEBUG, INFO (default), WARN, ERROR + - **Python version**: `p.text()` with default 3.13 + - **Chroma vector search**: `p.confirm()` (default: true) + - If yes, `p.select()` mode: local (default) vs remote + - If remote: `p.text()` for host, port, `p.confirm()` for SSL + - Show settings summary via `p.note()` before proceeding + +2. **`src/utils/settings-writer.ts`** — Write settings: + - Build flat key-value settings object matching SettingsDefaultsManager schema + - Merge with existing settings if upgrading (preserve user customizations) + - Write to `~/.claude-mem/settings.json` + - Create `~/.claude-mem/` directory if it doesn't exist + +### Verification +- [ ] Default settings mode skips all detailed prompts +- [ ] Custom settings validates all inputs +- [ ] Settings file written matches SettingsDefaultsManager schema exactly +- [ ] Existing settings preserved on upgrade + +--- + +## Phase 6: Installation Execution + +**Goal**: Clone repo, build plugin, register with IDEs, start worker. + +### Tasks + +1. **`src/steps/install.ts`** — Installation runner: + - Use `p.tasks()` for visual progress: + - **"Cloning claude-mem repository"**: `git clone --depth 1 https://github.com/thedotmack/claude-mem.git` to temp dir + - **"Installing dependencies"**: `npm install` in cloned repo + - **"Building plugin"**: `npm run build` in cloned repo + - **"Registering plugin"**: Copy plugin files to `~/.claude/plugins/marketplaces/thedotmack/` + - Create marketplace.json, plugin.json structure + - Register in `~/.claude/plugins/known_marketplaces.json` + - Add to `~/.claude/plugins/installed_plugins.json` + - Enable in `~/.claude/settings.json` under `enabledPlugins` + - **"Installing dependencies"** (in marketplace dir): `npm install` + - For Cursor (if selected): + - **"Configuring Cursor hooks"**: Run Cursor hooks installer logic + - Write hooks.json to `~/.cursor/` or project-level `.cursor/` + - Configure MCP in `.cursor/mcp.json` + +2. **`src/steps/worker.ts`** — Worker startup: + - Use `p.spinner()` for worker startup: + - Start worker: `bun plugin/scripts/worker-service.cjs` (from marketplace dir) + - Write PID file to `~/.claude-mem/worker.pid` + - Two-stage health check (copy pattern from OpenClaw installer): + - Stage 1: Poll `/api/health` — spinner message: "Starting worker service..." + - Stage 2: Poll `/api/readiness` — spinner message: "Initializing database..." + - Budget: 30 attempts, 1 second apart + - On success: `spinner.stop("Worker running on port {port}")` + - On failure: `spinner.error("Worker failed to start")`, show log path + +### Verification +- [ ] Plugin files exist at `~/.claude/plugins/marketplaces/thedotmack/` +- [ ] known_marketplaces.json updated +- [ ] installed_plugins.json updated +- [ ] settings.json has enabledPlugins entry +- [ ] Worker responds to `/api/health` with 200 +- [ ] Worker responds to `/api/readiness` with 200 + +--- + +## Phase 7: Completion & Summary + +**Goal**: Show success screen with configuration summary and next steps. + +### Tasks + +1. **`src/steps/complete.ts`** — Completion screen: + - `p.note()` with configuration summary: + - Provider + model + - IDEs configured + - Data directory + - Worker port + - Chroma enabled/disabled + - `p.note()` with next steps: + - "Open Claude Code and start a conversation — memory is automatic!" + - "View your memories: http://localhost:{port}" + - "Search past work: use /mem-search in Claude Code" + - If Cursor: "Open Cursor — hooks are active in your projects" + - `p.outro()` with styled completion message + +### Verification +- [ ] Summary accurately reflects chosen settings +- [ ] URLs use correct port from settings +- [ ] Next steps are relevant to selected IDEs + +--- + +## Phase 8: curl|bash Bootstrap Script + +**Goal**: Create the shell bootstrap script for `curl -fsSL https://install.cmem.ai | bash`. + +### Tasks + +1. **`install/public/install.sh`** — Bootstrap script: + - Check for Node.js >= 18 (required to run the installer) + - Download bundled installer JS to temp file + - Execute with `node` directly (preserves TTY for @clack/prompts) + - Cleanup temp file on exit (trap) + - Support `--non-interactive` flag passthrough + - Support `--provider=X --api-key=Y` flag passthrough + +2. **Update `install/vercel.json`** to serve `install.sh` alongside `openclaw.sh` + +### Verification +- [ ] `curl -fsSL https://install.cmem.ai | bash` downloads and runs installer +- [ ] Interactive prompts work after curl download +- [ ] Temp file cleaned up on success and failure +- [ ] Flags pass through correctly + +--- + +## Phase 9: Final Verification + +### Checks +- [ ] `npm run build` in installer/ produces single-file `dist/index.js` +- [ ] `node dist/index.js` runs full wizard flow +- [ ] Fresh install on clean system works end-to-end +- [ ] Upgrade path preserves existing settings +- [ ] Ctrl+C at any step exits cleanly +- [ ] Non-TTY shows error message +- [ ] All settings written match SettingsDefaultsManager.ts defaults schema +- [ ] Worker health check succeeds after install +- [ ] Plugin appears in Claude Code plugin list +- [ ] grep for deprecated/non-existent APIs returns 0 results +- [ ] No `require()` calls in source (ESM-only) +- [ ] No `chalk` imports (use picocolors) diff --git a/install/public/install.sh b/install/public/install.sh new file mode 100644 index 00000000..050e5340 --- /dev/null +++ b/install/public/install.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +# claude-mem installer bootstrap +# Usage: curl -fsSL https://install.cmem.ai | bash +# or: curl -fsSL https://install.cmem.ai | bash -s -- --provider=gemini --api-key=YOUR_KEY + +INSTALLER_URL="https://raw.githubusercontent.com/thedotmack/claude-mem/main/installer/dist/index.js" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +error() { echo -e "${RED}Error: $1${NC}" >&2; exit 1; } +info() { echo -e "${CYAN}$1${NC}"; } + +# Check Node.js +if ! command -v node &> /dev/null; then + error "Node.js is required but not found. Install from https://nodejs.org" +fi + +NODE_VERSION=$(node -v | sed 's/v//') +NODE_MAJOR=$(echo "$NODE_VERSION" | cut -d. -f1) +if [ "$NODE_MAJOR" -lt 18 ]; then + 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" "$@" =18.0.0" } +} diff --git a/installer/src/index.ts b/installer/src/index.ts new file mode 100644 index 00000000..509a5bac --- /dev/null +++ b/installer/src/index.ts @@ -0,0 +1,49 @@ +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 { + 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); +}); diff --git a/installer/src/steps/complete.ts b/installer/src/steps/complete.ts new file mode 100644 index 00000000..ca32bb8c --- /dev/null +++ b/installer/src/steps/complete.ts @@ -0,0 +1,56 @@ +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!')); +} diff --git a/installer/src/steps/dependencies.ts b/installer/src/steps/dependencies.ts new file mode 100644 index 00000000..242c7a99 --- /dev/null +++ b/installer/src/steps/dependencies.ts @@ -0,0 +1,168 @@ +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 { + 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; +} diff --git a/installer/src/steps/ide-selection.ts b/installer/src/steps/ide-selection.ts new file mode 100644 index 00000000..eefad6dd --- /dev/null +++ b/installer/src/steps/ide-selection.ts @@ -0,0 +1,32 @@ +import * as p from '@clack/prompts'; + +export type IDE = 'claude-code' | 'cursor'; + +export async function runIdeSelection(): Promise { + 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; +} diff --git a/installer/src/steps/install.ts b/installer/src/steps/install.ts new file mode 100644 index 00000000..88311f8b --- /dev/null +++ b/installer/src/steps/install.ts @@ -0,0 +1,167 @@ +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 { + 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)'); + } +} diff --git a/installer/src/steps/provider.ts b/installer/src/steps/provider.ts new file mode 100644 index 00000000..6fcb6bfa --- /dev/null +++ b/installer/src/steps/provider.ts @@ -0,0 +1,140 @@ +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 { + 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; +} diff --git a/installer/src/steps/settings.ts b/installer/src/steps/settings.ts new file mode 100644 index 00000000..547da927 --- /dev/null +++ b/installer/src/steps/settings.ts @@ -0,0 +1,174 @@ +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 { + 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; +} diff --git a/installer/src/steps/welcome.ts b/installer/src/steps/welcome.ts new file mode 100644 index 00000000..221a51bb --- /dev/null +++ b/installer/src/steps/welcome.ts @@ -0,0 +1,43 @@ +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 { + 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; +} diff --git a/installer/src/steps/worker.ts b/installer/src/steps/worker.ts new file mode 100644 index 00000000..595cb678 --- /dev/null +++ b/installer/src/steps/worker.ts @@ -0,0 +1,67 @@ +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 { + 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 { + 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`); + } +} diff --git a/installer/src/utils/dependencies.ts b/installer/src/utils/dependencies.ts new file mode 100644 index 00000000..74d1af9c --- /dev/null +++ b/installer/src/utils/dependencies.ts @@ -0,0 +1,74 @@ +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' }); + } +} diff --git a/installer/src/utils/settings-writer.ts b/installer/src/utils/settings-writer.ts new file mode 100644 index 00000000..8aca67d4 --- /dev/null +++ b/installer/src/utils/settings-writer.ts @@ -0,0 +1,82 @@ +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 { + const settings: Record = { + 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 = {}; + 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'); +} diff --git a/installer/src/utils/system.ts b/installer/src/utils/system.ts new file mode 100644 index 00000000..2c5ddcfc --- /dev/null +++ b/installer/src/utils/system.ts @@ -0,0 +1,49 @@ +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; +} diff --git a/installer/tsconfig.json b/installer/tsconfig.json new file mode 100644 index 00000000..69662ae0 --- /dev/null +++ b/installer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "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"] +}