mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
feat: add interactive CLI installer with @clack/prompts (#1093)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * docs: add animated installer implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: bigphoot <bigphoot@local> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> 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 <bigphoot@gmail.com>
This commit is contained in:
371
.claude/plans/animated-installer.md
Normal file
371
.claude/plans/animated-installer.md
Normal file
@@ -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<string \| symbol>` | API key input, port, data dir |
|
||||||
|
| `password(opts)` | `Promise<string \| symbol>` | API key input (masked) |
|
||||||
|
| `select(opts)` | `Promise<Value \| symbol>` | Provider, model, auth method |
|
||||||
|
| `multiselect(opts)` | `Promise<Value[] \| symbol>` | IDE selection, observation types |
|
||||||
|
| `confirm(opts)` | `Promise<boolean \| symbol>` | Enable Chroma, start worker |
|
||||||
|
| `spinner()` | `SpinnerResult` | Installing deps, building, starting worker |
|
||||||
|
| `progress(opts)` | `ProgressResult` | Multi-step installation progress |
|
||||||
|
| `tasks(tasks[])` | `Promise<void>` | Sequential install steps |
|
||||||
|
| `group(prompts, opts)` | `Promise<Results>` | 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)
|
||||||
59
install/public/install.sh
Normal file
59
install/public/install.sh
Normal file
@@ -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" "$@" </dev/tty
|
||||||
|
fi
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
{ "key": "Content-Type", "value": "text/plain; charset=utf-8" },
|
{ "key": "Content-Type", "value": "text/plain; charset=utf-8" },
|
||||||
{ "key": "Cache-Control", "value": "public, max-age=300, s-maxage=60" }
|
{ "key": "Cache-Control", "value": "public, max-age=300, s-maxage=60" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/(.*)\\.js",
|
||||||
|
"headers": [
|
||||||
|
{ "key": "Content-Type", "value": "application/javascript; charset=utf-8" },
|
||||||
|
{ "key": "Cache-Control", "value": "public, max-age=300, s-maxage=60" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
16
installer/build.mjs
Normal file
16
installer/build.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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');
|
||||||
21
installer/package.json
Normal file
21
installer/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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" }
|
||||||
|
}
|
||||||
49
installer/src/index.ts
Normal file
49
installer/src/index.ts
Normal file
@@ -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<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);
|
||||||
|
});
|
||||||
56
installer/src/steps/complete.ts
Normal file
56
installer/src/steps/complete.ts
Normal file
@@ -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!'));
|
||||||
|
}
|
||||||
168
installer/src/steps/dependencies.ts
Normal file
168
installer/src/steps/dependencies.ts
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
32
installer/src/steps/ide-selection.ts
Normal file
32
installer/src/steps/ide-selection.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
167
installer/src/steps/install.ts
Normal file
167
installer/src/steps/install.ts
Normal file
@@ -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<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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
140
installer/src/steps/provider.ts
Normal file
140
installer/src/steps/provider.ts
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
174
installer/src/steps/settings.ts
Normal file
174
installer/src/steps/settings.ts
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
43
installer/src/steps/welcome.ts
Normal file
43
installer/src/steps/welcome.ts
Normal file
@@ -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<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;
|
||||||
|
}
|
||||||
67
installer/src/steps/worker.ts
Normal file
67
installer/src/steps/worker.ts
Normal file
@@ -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<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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
installer/src/utils/dependencies.ts
Normal file
74
installer/src/utils/dependencies.ts
Normal file
@@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
82
installer/src/utils/settings-writer.ts
Normal file
82
installer/src/utils/settings-writer.ts
Normal file
@@ -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<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');
|
||||||
|
}
|
||||||
49
installer/src/utils/system.ts
Normal file
49
installer/src/utils/system.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
17
installer/tsconfig.json
Normal file
17
installer/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user