diff --git a/package.json b/package.json index 74cd1ad3..6933ab04 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "url": "https://github.com/thedotmack/claude-mem/issues" }, "type": "module", + "bin": { + "claude-mem": "./dist/npx-cli/index.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -97,12 +100,14 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.76", + "@clack/prompts": "^0.9.1", "@modelcontextprotocol/sdk": "^1.25.1", "ansi-to-html": "^0.7.2", "dompurify": "^3.3.1", "express": "^4.18.2", "glob": "^11.0.3", "handlebars": "^4.7.8", + "picocolors": "^1.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "yaml": "^2.8.2", diff --git a/src/npx-cli/commands/ide-detection.ts b/src/npx-cli/commands/ide-detection.ts new file mode 100644 index 00000000..2d419bf3 --- /dev/null +++ b/src/npx-cli/commands/ide-detection.ts @@ -0,0 +1,174 @@ +/** + * IDE Auto-Detection + * + * Detects which AI coding IDEs / tools are installed on the system by + * probing known config directories and checking for binaries in PATH. + * + * Pure Node.js — no Bun APIs used. + */ +import { execSync } from 'child_process'; +import { existsSync, readdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { IS_WINDOWS } from '../utils/paths.js'; + +// --------------------------------------------------------------------------- +// IDE type and metadata +// --------------------------------------------------------------------------- + +export interface IDEInfo { + /** Machine-readable identifier. */ + id: string; + /** Human-readable label for display in prompts. */ + label: string; + /** Whether the IDE was detected on this system. */ + detected: boolean; + /** Whether claude-mem has implemented setup for this IDE. */ + supported: boolean; + /** Short hint text shown in the multi-select. */ + hint?: string; +} + +// --------------------------------------------------------------------------- +// PATH helper +// --------------------------------------------------------------------------- + +function isCommandInPath(command: string): boolean { + try { + const whichCommand = IS_WINDOWS ? 'where' : 'which'; + execSync(`${whichCommand} ${command}`, { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// VS Code extension directory scanner +// --------------------------------------------------------------------------- + +function hasVscodeExtension(extensionNameFragment: string): boolean { + const extensionsDirectory = join(homedir(), '.vscode', 'extensions'); + if (!existsSync(extensionsDirectory)) return false; + try { + const entries = readdirSync(extensionsDirectory); + return entries.some((entry) => entry.toLowerCase().includes(extensionNameFragment.toLowerCase())); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Detection map +// --------------------------------------------------------------------------- + +/** + * Detect all known IDEs and return an array of `IDEInfo` objects. + * Each entry indicates whether the IDE was found and whether claude-mem + * currently supports setting it up. + */ +export function detectInstalledIDEs(): IDEInfo[] { + const home = homedir(); + + return [ + { + id: 'claude-code', + label: 'Claude Code', + detected: existsSync(join(home, '.claude')), + supported: true, + hint: 'recommended', + }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + detected: existsSync(join(home, '.gemini')), + supported: false, + hint: 'coming soon', + }, + { + id: 'opencode', + label: 'OpenCode', + detected: + existsSync(join(home, '.config', 'opencode')) || isCommandInPath('opencode'), + supported: false, + hint: 'coming soon', + }, + { + id: 'openclaw', + label: 'OpenClaw', + detected: existsSync(join(home, '.openclaw')), + supported: false, + hint: 'coming soon', + }, + { + id: 'windsurf', + label: 'Windsurf', + detected: existsSync(join(home, '.codeium', 'windsurf')), + supported: false, + hint: 'coming soon', + }, + { + id: 'codex-cli', + label: 'Codex CLI', + detected: existsSync(join(home, '.codex')), + supported: false, + hint: 'coming soon', + }, + { + id: 'cursor', + label: 'Cursor', + detected: existsSync(join(home, '.cursor')), + supported: true, + }, + { + id: 'copilot-cli', + label: 'Copilot CLI', + detected: isCommandInPath('copilot'), + supported: false, + hint: 'coming soon', + }, + { + id: 'antigravity', + label: 'Antigravity', + detected: existsSync(join(home, '.gemini', 'antigravity')), + supported: false, + hint: 'coming soon', + }, + { + id: 'goose', + label: 'Goose', + detected: + existsSync(join(home, '.config', 'goose')) || isCommandInPath('goose'), + supported: false, + hint: 'coming soon', + }, + { + id: 'crush', + label: 'Crush', + detected: isCommandInPath('crush'), + supported: false, + hint: 'coming soon', + }, + { + id: 'roo-code', + label: 'Roo Code', + detected: hasVscodeExtension('roo-code'), + supported: false, + hint: 'coming soon', + }, + { + id: 'warp', + label: 'Warp', + detected: existsSync(join(home, '.warp')) || isCommandInPath('warp'), + supported: false, + hint: 'coming soon', + }, + ]; +} + +/** + * Return only the IDEs that were detected on this system. + */ +export function getDetectedIDEs(): IDEInfo[] { + return detectInstalledIDEs().filter((ide) => ide.detected); +} diff --git a/src/npx-cli/commands/install.ts b/src/npx-cli/commands/install.ts new file mode 100644 index 00000000..30b060f8 --- /dev/null +++ b/src/npx-cli/commands/install.ts @@ -0,0 +1,374 @@ +/** + * Install command for `npx claude-mem install`. + * + * Replaces the git-clone + build workflow. The npm package already ships + * a pre-built `plugin/` directory; this command copies it into the right + * locations and registers it with Claude Code. + * + * Pure Node.js — no Bun APIs used. + */ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { execSync } from 'child_process'; +import { cpSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + claudeSettingsPath, + ensureDirectoryExists, + installedPluginsPath, + IS_WINDOWS, + knownMarketplacesPath, + marketplaceDirectory, + npmPackagePluginDirectory, + npmPackageRootDirectory, + pluginCacheDirectory, + pluginsDirectory, + readJsonFileSafe, + readPluginVersion, + writeJsonFileAtomic, +} from '../utils/paths.js'; +import { detectInstalledIDEs } from './ide-detection.js'; + +// --------------------------------------------------------------------------- +// Registration helpers (mirror installer/src/steps/install.ts) +// --------------------------------------------------------------------------- + +function registerMarketplace(): void { + const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath()); + + knownMarketplaces['thedotmack'] = { + source: { + source: 'github', + repo: 'thedotmack/claude-mem', + }, + installLocation: marketplaceDirectory(), + lastUpdated: new Date().toISOString(), + autoUpdate: true, + }; + + ensureDirectoryExists(pluginsDirectory()); + writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces); +} + +function registerPlugin(version: string): void { + const installedPlugins = readJsonFileSafe(installedPluginsPath()); + + if (!installedPlugins.version) installedPlugins.version = 2; + if (!installedPlugins.plugins) installedPlugins.plugins = {}; + + const cachePath = pluginCacheDirectory(version); + const now = new Date().toISOString(); + + installedPlugins.plugins['claude-mem@thedotmack'] = [ + { + scope: 'user', + installPath: cachePath, + version, + installedAt: now, + lastUpdated: now, + }, + ]; + + writeJsonFileAtomic(installedPluginsPath(), installedPlugins); +} + +function enablePluginInClaudeSettings(): void { + const settings = readJsonFileSafe(claudeSettingsPath()); + + if (!settings.enabledPlugins) settings.enabledPlugins = {}; + settings.enabledPlugins['claude-mem@thedotmack'] = true; + + writeJsonFileAtomic(claudeSettingsPath(), settings); +} + +// --------------------------------------------------------------------------- +// IDE setup dispatcher +// --------------------------------------------------------------------------- + +function setupIDEs(selectedIDEs: string[]): void { + for (const ideId of selectedIDEs) { + switch (ideId) { + case 'claude-code': + // Claude Code picks up the plugin via marketplace registration — nothing + // else to do beyond what registerMarketplace / registerPlugin already did. + p.log.success('Claude Code: plugin registered via marketplace.'); + break; + + case 'cursor': + p.log.info('Cursor: hook configuration available after first launch.'); + p.log.info(` Run: npx claude-mem cursor-setup (coming soon)`); + break; + + default: { + const allIDEs = detectInstalledIDEs(); + const ide = allIDEs.find((i) => i.id === ideId); + if (ide && !ide.supported) { + p.log.warn(`Support for ${ide.label} coming soon.`); + } + break; + } + } + } +} + +// --------------------------------------------------------------------------- +// Interactive IDE selection +// --------------------------------------------------------------------------- + +async function promptForIDESelection(): Promise { + const detectedIDEs = detectInstalledIDEs(); + const detected = detectedIDEs.filter((ide) => ide.detected); + + if (detected.length === 0) { + p.log.warn('No supported IDEs detected. Installing for Claude Code by default.'); + return ['claude-code']; + } + + const options = detected.map((ide) => ({ + value: ide.id, + label: ide.label, + hint: ide.supported ? ide.hint : 'coming soon', + })); + + const result = await p.multiselect({ + message: 'Which IDEs do you use?', + options, + initialValues: detected + .filter((ide) => ide.supported) + .map((ide) => ide.id), + required: true, + }); + + if (p.isCancel(result)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + return result as string[]; +} + +// --------------------------------------------------------------------------- +// Core copy logic +// --------------------------------------------------------------------------- + +function copyPluginToMarketplace(): void { + const marketplaceDir = marketplaceDirectory(); + + ensureDirectoryExists(marketplaceDir); + + // Copy the entire npm package (not just plugin/) so that package.json, + // node_modules, and scripts are all present in the marketplace dir. + const packageRoot = npmPackageRootDirectory(); + cpSync(packageRoot, marketplaceDir, { + recursive: true, + force: true, + filter: (source) => { + // Skip .git and other unnecessary directories + if (source.includes('.git') && !source.includes('.claude-plugin')) return false; + if (source.endsWith('.tgz')) return false; + return true; + }, + }); +} + +function copyPluginToCache(version: string): void { + const sourcePluginDirectory = npmPackagePluginDirectory(); + const cachePath = pluginCacheDirectory(version); + + ensureDirectoryExists(cachePath); + cpSync(sourcePluginDirectory, cachePath, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// npm install in marketplace dir +// --------------------------------------------------------------------------- + +function runNpmInstallInMarketplace(): void { + const marketplaceDir = marketplaceDirectory(); + const packageJsonPath = join(marketplaceDir, 'package.json'); + + if (!existsSync(packageJsonPath)) return; + + execSync('npm install --production', { + cwd: marketplaceDir, + stdio: 'pipe', + ...(IS_WINDOWS ? { shell: true as const } : {}), + }); +} + +// --------------------------------------------------------------------------- +// Trigger smart-install for Bun / uv +// --------------------------------------------------------------------------- + +function runSmartInstall(): void { + const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js'); + + if (!existsSync(smartInstallPath)) { + p.log.warn('smart-install.js not found — skipping Bun/uv auto-install.'); + return; + } + + try { + execSync(`node "${smartInstallPath}"`, { + stdio: 'inherit', + ...(IS_WINDOWS ? { shell: true as const } : {}), + }); + } catch { + p.log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.'); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface InstallOptions { + /** When provided, skip the interactive IDE multi-select and use this IDE. */ + ide?: string; +} + +export async function runInstallCommand(options: InstallOptions = {}): Promise { + const version = readPluginVersion(); + + p.intro(pc.bgCyan(pc.black(' claude-mem install '))); + p.log.info(`Version: ${pc.cyan(version)}`); + p.log.info(`Platform: ${process.platform} (${process.arch})`); + + // Check for existing installation + const marketplaceDir = marketplaceDirectory(); + const alreadyInstalled = existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json')); + + if (alreadyInstalled) { + // Read existing version + try { + const existingPluginJson = JSON.parse( + readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'), + ); + p.log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`); + } catch { + p.log.warn('Existing installation detected.'); + } + + if (process.stdin.isTTY) { + const shouldContinue = await p.confirm({ + message: 'Overwrite existing installation?', + initialValue: true, + }); + + if (p.isCancel(shouldContinue) || !shouldContinue) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + } + } + + // IDE selection + let selectedIDEs: string[]; + if (options.ide) { + selectedIDEs = [options.ide]; + const allIDEs = detectInstalledIDEs(); + const match = allIDEs.find((i) => i.id === options.ide); + if (match && !match.supported) { + p.log.error(`Support for ${match.label} coming soon.`); + process.exit(1); + } + if (!match) { + p.log.error(`Unknown IDE: ${options.ide}`); + p.log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`); + process.exit(1); + } + } else if (process.stdin.isTTY) { + selectedIDEs = await promptForIDESelection(); + } else { + // Non-interactive: default to claude-code + selectedIDEs = ['claude-code']; + } + + // Run tasks + await p.tasks([ + { + title: 'Copying plugin files', + task: async (message) => { + message('Copying to marketplace directory...'); + copyPluginToMarketplace(); + return `Plugin files copied ${pc.green('OK')}`; + }, + }, + { + title: 'Caching plugin version', + task: async (message) => { + message(`Caching v${version}...`); + copyPluginToCache(version); + return `Plugin cached (v${version}) ${pc.green('OK')}`; + }, + }, + { + title: 'Registering marketplace', + task: async () => { + registerMarketplace(); + return `Marketplace registered ${pc.green('OK')}`; + }, + }, + { + title: 'Registering plugin', + task: async () => { + registerPlugin(version); + return `Plugin registered ${pc.green('OK')}`; + }, + }, + { + title: 'Enabling plugin in Claude settings', + task: async () => { + enablePluginInClaudeSettings(); + return `Plugin enabled ${pc.green('OK')}`; + }, + }, + { + title: 'Installing dependencies', + task: async (message) => { + message('Running npm install...'); + try { + runNpmInstallInMarketplace(); + return `Dependencies installed ${pc.green('OK')}`; + } catch { + return `Dependencies may need manual install ${pc.yellow('!')}`; + } + }, + }, + { + title: 'Setting up Bun and uv', + task: async (message) => { + message('Running smart-install...'); + try { + runSmartInstall(); + return `Runtime dependencies ready ${pc.green('OK')}`; + } catch { + return `Runtime setup may need attention ${pc.yellow('!')}`; + } + }, + }, + ]); + + // IDE-specific setup + setupIDEs(selectedIDEs); + + // Summary + const summaryLines = [ + `Version: ${pc.cyan(version)}`, + `Plugin dir: ${pc.cyan(marketplaceDir)}`, + `IDEs: ${pc.cyan(selectedIDEs.join(', '))}`, + ]; + + p.note(summaryLines.join('\n'), 'Installation Complete'); + + const nextSteps = [ + 'Open Claude Code and start a conversation -- memory is automatic!', + `View your memories: ${pc.underline('http://localhost:37777')}`, + `Search past work: use ${pc.bold('/mem-search')} in Claude Code`, + `Start worker: ${pc.bold('npx claude-mem start')}`, + ]; + + p.note(nextSteps.join('\n'), 'Next Steps'); + + p.outro(pc.green('claude-mem installed successfully!')); +} diff --git a/src/npx-cli/commands/runtime.ts b/src/npx-cli/commands/runtime.ts new file mode 100644 index 00000000..c6324a58 --- /dev/null +++ b/src/npx-cli/commands/runtime.ts @@ -0,0 +1,184 @@ +/** + * Runtime command routing for `npx claude-mem start|stop|restart|status|search|transcript`. + * + * These commands delegate to the installed plugin's worker-service.cjs via Bun, + * or hit the worker's HTTP API directly (for `search`). + * + * Pure Node.js — no Bun APIs used. + */ +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import pc from 'picocolors'; +import { resolveBunBinaryPath } from '../utils/bun-resolver.js'; +import { isPluginInstalled, marketplaceDirectory } from '../utils/paths.js'; + +// --------------------------------------------------------------------------- +// Installation guard +// --------------------------------------------------------------------------- + +function ensureInstalledOrExit(): void { + if (!isPluginInstalled()) { + console.error(pc.red('claude-mem is not installed.')); + console.error(`Run: ${pc.bold('npx claude-mem install')}`); + process.exit(1); + } +} + +// --------------------------------------------------------------------------- +// Bun guard +// --------------------------------------------------------------------------- + +function resolveBunOrExit(): string { + const bunPath = resolveBunBinaryPath(); + if (!bunPath) { + console.error(pc.red('Bun not found.')); + console.error('Install Bun: https://bun.sh'); + console.error('After installation, restart your terminal.'); + process.exit(1); + } + return bunPath; +} + +// --------------------------------------------------------------------------- +// Worker-service path +// --------------------------------------------------------------------------- + +function workerServiceScriptPath(): string { + return join(marketplaceDirectory(), 'plugin', 'scripts', 'worker-service.cjs'); +} + +// --------------------------------------------------------------------------- +// Spawn helper +// --------------------------------------------------------------------------- + +function spawnBunWorkerCommand(command: string, extraArgs: string[] = []): void { + ensureInstalledOrExit(); + const bunPath = resolveBunOrExit(); + const workerScript = workerServiceScriptPath(); + + if (!existsSync(workerScript)) { + console.error(pc.red(`Worker script not found at: ${workerScript}`)); + console.error('The installation may be corrupted. Try: npx claude-mem install'); + process.exit(1); + } + + const args = [workerScript, command, ...extraArgs]; + + const child = spawn(bunPath, args, { + stdio: 'inherit', + cwd: marketplaceDirectory(), + env: process.env, + }); + + child.on('error', (error) => { + console.error(pc.red(`Failed to start Bun: ${error.message}`)); + process.exit(1); + }); + + child.on('close', (exitCode) => { + process.exit(exitCode ?? 0); + }); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function runStartCommand(): void { + spawnBunWorkerCommand('start'); +} + +export function runStopCommand(): void { + spawnBunWorkerCommand('stop'); +} + +export function runRestartCommand(): void { + spawnBunWorkerCommand('restart'); +} + +export function runStatusCommand(): void { + spawnBunWorkerCommand('status'); +} + +/** + * Search the worker API at `GET /api/search?q=`. + */ +export async function runSearchCommand(queryParts: string[]): Promise { + ensureInstalledOrExit(); + + const query = queryParts.join(' ').trim(); + if (!query) { + console.error(pc.red('Usage: npx claude-mem search ')); + process.exit(1); + } + + const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777'; + const searchUrl = `http://127.0.0.1:${workerPort}/api/search?q=${encodeURIComponent(query)}`; + + try { + const response = await fetch(searchUrl); + + if (!response.ok) { + if (response.status === 404) { + console.error(pc.red('Search endpoint not found. Is the worker running?')); + console.error(`Try: ${pc.bold('npx claude-mem start')}`); + process.exit(1); + } + console.error(pc.red(`Search failed: HTTP ${response.status}`)); + process.exit(1); + } + + const data = await response.json(); + + if (typeof data === 'object' && data !== null) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(data); + } + } catch (error: any) { + if (error?.cause?.code === 'ECONNREFUSED' || error?.message?.includes('ECONNREFUSED')) { + console.error(pc.red('Worker is not running.')); + console.error(`Start it with: ${pc.bold('npx claude-mem start')}`); + process.exit(1); + } + console.error(pc.red(`Search failed: ${error.message}`)); + process.exit(1); + } +} + +/** + * Start the transcript watcher via Bun. + */ +export function runTranscriptWatchCommand(): void { + ensureInstalledOrExit(); + const bunPath = resolveBunOrExit(); + + const transcriptWatcherPath = join( + marketplaceDirectory(), + 'plugin', + 'scripts', + 'transcript-watcher.cjs', + ); + + if (!existsSync(transcriptWatcherPath)) { + // Fall back to worker-service with transcript subcommand + spawnBunWorkerCommand('transcript', ['watch']); + return; + } + + const child = spawn(bunPath, [transcriptWatcherPath, 'watch'], { + stdio: 'inherit', + cwd: marketplaceDirectory(), + env: process.env, + }); + + child.on('error', (error) => { + console.error(pc.red(`Failed to start transcript watcher: ${error.message}`)); + process.exit(1); + }); + + child.on('close', (exitCode) => { + process.exit(exitCode ?? 0); + }); +} diff --git a/src/npx-cli/commands/uninstall.ts b/src/npx-cli/commands/uninstall.ts new file mode 100644 index 00000000..431723ce --- /dev/null +++ b/src/npx-cli/commands/uninstall.ts @@ -0,0 +1,171 @@ +/** + * Uninstall command for `npx claude-mem uninstall`. + * + * Removes the plugin from the marketplace directory, cache, plugin + * registrations, and Claude settings. Optionally cleans up IDE-specific + * configurations. + * + * Pure Node.js — no Bun APIs used. + */ +import * as p from '@clack/prompts'; +import pc from 'picocolors'; +import { existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { + claudeSettingsPath, + installedPluginsPath, + isPluginInstalled, + knownMarketplacesPath, + marketplaceDirectory, + pluginsDirectory, + readJsonFileSafe, + writeJsonFileAtomic, +} from '../utils/paths.js'; + +// --------------------------------------------------------------------------- +// Cleanup helpers +// --------------------------------------------------------------------------- + +function removeMarketplaceDirectory(): boolean { + const marketplaceDir = marketplaceDirectory(); + if (existsSync(marketplaceDir)) { + rmSync(marketplaceDir, { recursive: true, force: true }); + return true; + } + return false; +} + +function removeCacheDirectory(): boolean { + const cacheBaseDirectory = join(pluginsDirectory(), 'cache', 'thedotmack'); + if (existsSync(cacheBaseDirectory)) { + rmSync(cacheBaseDirectory, { recursive: true, force: true }); + return true; + } + return false; +} + +function removeFromKnownMarketplaces(): void { + const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath()); + if (knownMarketplaces['thedotmack']) { + delete knownMarketplaces['thedotmack']; + writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces); + } +} + +function removeFromInstalledPlugins(): void { + const installedPlugins = readJsonFileSafe(installedPluginsPath()); + if (installedPlugins.plugins?.['claude-mem@thedotmack']) { + delete installedPlugins.plugins['claude-mem@thedotmack']; + writeJsonFileAtomic(installedPluginsPath(), installedPlugins); + } +} + +function removeFromClaudeSettings(): void { + const settings = readJsonFileSafe(claudeSettingsPath()); + if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) { + delete settings.enabledPlugins['claude-mem@thedotmack']; + writeJsonFileAtomic(claudeSettingsPath(), settings); + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function runUninstallCommand(): Promise { + p.intro(pc.bgRed(pc.white(' claude-mem uninstall '))); + + if (!isPluginInstalled()) { + p.log.warn('claude-mem does not appear to be installed.'); + + // Still offer to clean up partial state + if (process.stdin.isTTY) { + const shouldCleanup = await p.confirm({ + message: 'Clean up any remaining registration data anyway?', + initialValue: false, + }); + + if (p.isCancel(shouldCleanup) || !shouldCleanup) { + p.outro('Nothing to do.'); + return; + } + } else { + p.outro('Nothing to do.'); + return; + } + } else if (process.stdin.isTTY) { + const shouldContinue = await p.confirm({ + message: 'Are you sure you want to uninstall claude-mem?', + initialValue: false, + }); + + if (p.isCancel(shouldContinue) || !shouldContinue) { + p.cancel('Uninstall cancelled.'); + return; + } + } + + // Stop the worker first (best-effort) + try { + const workerPort = process.env.CLAUDE_MEM_WORKER_PORT || '37777'; + await fetch(`http://127.0.0.1:${workerPort}/api/admin/shutdown`, { + method: 'POST', + signal: AbortSignal.timeout(5000), + }); + p.log.info('Worker service stopped.'); + } catch { + // Worker may not be running — that is fine + } + + await p.tasks([ + { + title: 'Removing marketplace directory', + task: async () => { + const removed = removeMarketplaceDirectory(); + return removed + ? `Marketplace directory removed ${pc.green('OK')}` + : `Marketplace directory not found ${pc.dim('skipped')}`; + }, + }, + { + title: 'Removing cache directory', + task: async () => { + const removed = removeCacheDirectory(); + return removed + ? `Cache directory removed ${pc.green('OK')}` + : `Cache directory not found ${pc.dim('skipped')}`; + }, + }, + { + title: 'Removing marketplace registration', + task: async () => { + removeFromKnownMarketplaces(); + return `Marketplace registration removed ${pc.green('OK')}`; + }, + }, + { + title: 'Removing plugin registration', + task: async () => { + removeFromInstalledPlugins(); + return `Plugin registration removed ${pc.green('OK')}`; + }, + }, + { + title: 'Removing from Claude settings', + task: async () => { + removeFromClaudeSettings(); + return `Claude settings updated ${pc.green('OK')}`; + }, + }, + ]); + + p.note( + [ + `Your data directory at ${pc.cyan('~/.claude-mem')} was preserved.`, + 'To remove it manually: rm -rf ~/.claude-mem', + ].join('\n'), + 'Note', + ); + + p.outro(pc.green('claude-mem has been uninstalled.')); +} diff --git a/src/npx-cli/index.ts b/src/npx-cli/index.ts new file mode 100644 index 00000000..68b0cd97 --- /dev/null +++ b/src/npx-cli/index.ts @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * NPX CLI entry point for claude-mem. + * + * Usage: + * npx claude-mem → interactive install + * npx claude-mem install → interactive install + * npx claude-mem install --ide → direct IDE setup + * npx claude-mem update → update to latest version + * npx claude-mem uninstall → remove plugin and IDE configs + * npx claude-mem version → print version + * npx claude-mem start → start worker service + * npx claude-mem stop → stop worker service + * npx claude-mem restart → restart worker service + * npx claude-mem status → show worker status + * npx claude-mem search → search observations + * npx claude-mem transcript watch → start transcript watcher + * + * This file is pure Node.js — Bun is NOT required for install commands. + * Runtime commands (`start`, `stop`, etc.) delegate to Bun via the installed plugin. + */ +import pc from 'picocolors'; +import { readPluginVersion } from './utils/paths.js'; + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const command = args[0]?.toLowerCase() ?? ''; + +// --------------------------------------------------------------------------- +// Help text +// --------------------------------------------------------------------------- + +function printHelp(): void { + const version = readPluginVersion(); + + console.log(` +${pc.bold('claude-mem')} v${version} — persistent memory for AI coding assistants + +${pc.bold('Install Commands')} (no Bun required): + ${pc.cyan('npx claude-mem')} Interactive install + ${pc.cyan('npx claude-mem install')} Interactive install + ${pc.cyan('npx claude-mem install --ide ')} Install for specific IDE + ${pc.cyan('npx claude-mem update')} Update to latest version + ${pc.cyan('npx claude-mem uninstall')} Remove plugin and configs + ${pc.cyan('npx claude-mem version')} Print version + +${pc.bold('Runtime Commands')} (requires Bun, delegates to installed plugin): + ${pc.cyan('npx claude-mem start')} Start worker service + ${pc.cyan('npx claude-mem stop')} Stop worker service + ${pc.cyan('npx claude-mem restart')} Restart worker service + ${pc.cyan('npx claude-mem status')} Show worker status + ${pc.cyan('npx claude-mem search ')} Search observations + ${pc.cyan('npx claude-mem transcript watch')} Start transcript watcher + +${pc.bold('IDE Identifiers')}: + claude-code, cursor, gemini-cli, opencode, openclaw, + windsurf, codex-cli, copilot-cli, antigravity, goose, + crush, roo-code, warp +`); +} + +// --------------------------------------------------------------------------- +// Command routing +// --------------------------------------------------------------------------- + +async function main(): Promise { + switch (command) { + // -- No command: default to install ------------------------------------ + case '': { + const { runInstallCommand } = await import('./commands/install.js'); + await runInstallCommand(); + break; + } + + // -- Install ----------------------------------------------------------- + case 'install': { + const ideIndex = args.indexOf('--ide'); + const ideValue = ideIndex !== -1 ? args[ideIndex + 1] : undefined; + + const { runInstallCommand } = await import('./commands/install.js'); + await runInstallCommand({ ide: ideValue }); + break; + } + + // -- Update (alias for install — overwrite with latest) ---------------- + case 'update': + case 'upgrade': { + const { runInstallCommand } = await import('./commands/install.js'); + await runInstallCommand(); + break; + } + + // -- Uninstall --------------------------------------------------------- + case 'uninstall': + case 'remove': { + const { runUninstallCommand } = await import('./commands/uninstall.js'); + await runUninstallCommand(); + break; + } + + // -- Version ----------------------------------------------------------- + case 'version': + case '--version': + case '-v': { + console.log(readPluginVersion()); + break; + } + + // -- Help -------------------------------------------------------------- + case 'help': + case '--help': + case '-h': { + printHelp(); + break; + } + + // -- Runtime: start / stop / restart / status -------------------------- + case 'start': { + const { runStartCommand } = await import('./commands/runtime.js'); + runStartCommand(); + break; + } + case 'stop': { + const { runStopCommand } = await import('./commands/runtime.js'); + runStopCommand(); + break; + } + case 'restart': { + const { runRestartCommand } = await import('./commands/runtime.js'); + runRestartCommand(); + break; + } + case 'status': { + const { runStatusCommand } = await import('./commands/runtime.js'); + runStatusCommand(); + break; + } + + // -- Search ------------------------------------------------------------ + case 'search': { + const { runSearchCommand } = await import('./commands/runtime.js'); + await runSearchCommand(args.slice(1)); + break; + } + + // -- Transcript -------------------------------------------------------- + case 'transcript': { + const subCommand = args[1]?.toLowerCase(); + if (subCommand === 'watch') { + const { runTranscriptWatchCommand } = await import('./commands/runtime.js'); + runTranscriptWatchCommand(); + } else { + console.error(pc.red(`Unknown transcript subcommand: ${subCommand ?? '(none)'}`)); + console.error(`Usage: npx claude-mem transcript watch`); + process.exit(1); + } + break; + } + + // -- Unknown ----------------------------------------------------------- + default: { + console.error(pc.red(`Unknown command: ${command}`)); + console.error(`Run ${pc.bold('npx claude-mem --help')} for usage information.`); + process.exit(1); + } + } +} + +main().catch((error) => { + console.error(pc.red('Fatal error:'), error.message || error); + process.exit(1); +}); diff --git a/src/npx-cli/utils/bun-resolver.ts b/src/npx-cli/utils/bun-resolver.ts new file mode 100644 index 00000000..a019b7bd --- /dev/null +++ b/src/npx-cli/utils/bun-resolver.ts @@ -0,0 +1,85 @@ +/** + * Bun binary resolution utility. + * + * Extracted from `plugin/scripts/bun-runner.js` so that the NPX CLI + * can locate Bun without duplicating the search logic. + * + * Pure Node.js — no Bun APIs used. + */ +import { spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { IS_WINDOWS } from './paths.js'; + +/** + * Well-known locations where Bun might be installed, beyond PATH. + * Order matches the search priority in bun-runner.js and smart-install.js. + */ +function bunCandidatePaths(): string[] { + if (IS_WINDOWS) { + return [ + join(homedir(), '.bun', 'bin', 'bun.exe'), + join(process.env.USERPROFILE || homedir(), '.bun', 'bin', 'bun.exe'), + ]; + } + + return [ + join(homedir(), '.bun', 'bin', 'bun'), + '/usr/local/bin/bun', + '/opt/homebrew/bin/bun', + '/home/linuxbrew/.linuxbrew/bin/bun', + ]; +} + +/** + * Attempt to locate the Bun executable. + * + * 1. Check PATH via `which` / `where`. + * 2. Probe well-known installation directories. + * + * Returns the absolute path to the binary, `'bun'` if it is in PATH, + * or `null` if Bun cannot be found. + */ +export function resolveBunBinaryPath(): string | null { + // Try PATH first + const whichCommand = IS_WINDOWS ? 'where' : 'which'; + const pathCheck = spawnSync(whichCommand, ['bun'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + shell: IS_WINDOWS, + }); + + if (pathCheck.status === 0 && pathCheck.stdout.trim()) { + return 'bun'; // Available in PATH — use short name + } + + // Probe known install locations + for (const candidatePath of bunCandidatePaths()) { + if (existsSync(candidatePath)) { + return candidatePath; + } + } + + return null; +} + +/** + * Get the installed Bun version string (e.g. `"1.2.3"`), or `null` + * if Bun is not available. + */ +export function getBunVersionString(): string | null { + const bunPath = resolveBunBinaryPath(); + if (!bunPath) return null; + + try { + const result = spawnSync(bunPath, ['--version'], { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + shell: IS_WINDOWS, + }); + return result.status === 0 ? result.stdout.trim() : null; + } catch { + return null; + } +} diff --git a/src/npx-cli/utils/paths.ts b/src/npx-cli/utils/paths.ts new file mode 100644 index 00000000..32cc1a97 --- /dev/null +++ b/src/npx-cli/utils/paths.ts @@ -0,0 +1,152 @@ +/** + * Shared path utilities for the NPX CLI. + * + * All platform-specific path logic is centralized here so that every command + * resolves directories in exactly the same way, regardless of OS. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- + +export const IS_WINDOWS = process.platform === 'win32'; + +// --------------------------------------------------------------------------- +// Core paths +// --------------------------------------------------------------------------- + +/** Root of the Claude Code config directory. */ +export function claudeConfigDirectory(): string { + return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); +} + +/** Marketplace install directory for thedotmack. */ +export function marketplaceDirectory(): string { + return join(claudeConfigDirectory(), 'plugins', 'marketplaces', 'thedotmack'); +} + +/** Top-level plugins directory. */ +export function pluginsDirectory(): string { + return join(claudeConfigDirectory(), 'plugins'); +} + +/** Path to `known_marketplaces.json`. */ +export function knownMarketplacesPath(): string { + return join(pluginsDirectory(), 'known_marketplaces.json'); +} + +/** Path to `installed_plugins.json`. */ +export function installedPluginsPath(): string { + return join(pluginsDirectory(), 'installed_plugins.json'); +} + +/** Path to `~/.claude/settings.json`. */ +export function claudeSettingsPath(): string { + return join(claudeConfigDirectory(), 'settings.json'); +} + +/** Plugin cache directory for a specific version. */ +export function pluginCacheDirectory(version: string): string { + return join(pluginsDirectory(), 'cache', 'thedotmack', 'claude-mem', version); +} + +/** claude-mem data directory (default `~/.claude-mem`). */ +export function claudeMemDataDirectory(): string { + return join(homedir(), '.claude-mem'); +} + +// --------------------------------------------------------------------------- +// NPM package root (where the NPX package lives on disk) +// --------------------------------------------------------------------------- + +/** + * Resolve the root of the installed npm package. + * + * The CLI entry point lives at `/dist/cli/index.js`. Walking up + * from `import.meta.url` (or `__dirname` equivalent) we reach the + * package root where `plugin/` can be found. + */ +export function npmPackageRootDirectory(): string { + const currentFilePath = fileURLToPath(import.meta.url); + // /dist/npx-cli/utils/paths.js -> up 3 levels -> + return join(dirname(currentFilePath), '..', '..', '..'); +} + +/** + * Path to the `plugin/` directory bundled inside the npm package. + */ +export function npmPackagePluginDirectory(): string { + return join(npmPackageRootDirectory(), 'plugin'); +} + +// --------------------------------------------------------------------------- +// Version helpers +// --------------------------------------------------------------------------- + +/** + * Read the current plugin version from the npm package's + * `plugin/.claude-plugin/plugin.json` (preferred) or from `package.json`. + */ +export function readPluginVersion(): string { + // Try plugin.json first (authoritative for plugin version) + const pluginJsonPath = join(npmPackagePluginDirectory(), '.claude-plugin', 'plugin.json'); + if (existsSync(pluginJsonPath)) { + try { + const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8')); + if (pluginJson.version) return pluginJson.version; + } catch { + // Fall through to package.json + } + } + + // Fall back to package.json at package root + const packageJsonPath = join(npmPackageRootDirectory(), 'package.json'); + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')); + if (packageJson.version) return packageJson.version; + } catch { + // Unable to read + } + } + + return '0.0.0'; +} + +// --------------------------------------------------------------------------- +// Installation detection +// --------------------------------------------------------------------------- + +/** Returns true if the plugin appears to be installed in the marketplace dir. */ +export function isPluginInstalled(): boolean { + const marketplaceDir = marketplaceDirectory(); + return existsSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json')); +} + +// --------------------------------------------------------------------------- +// JSON file helpers +// --------------------------------------------------------------------------- + +export function ensureDirectoryExists(directoryPath: string): void { + if (!existsSync(directoryPath)) { + mkdirSync(directoryPath, { recursive: true }); + } +} + +export function readJsonFileSafe(filepath: string): any { + if (!existsSync(filepath)) return {}; + try { + return JSON.parse(readFileSync(filepath, 'utf-8')); + } catch { + return {}; + } +} + +export function writeJsonFileAtomic(filepath: string, data: any): void { + ensureDirectoryExists(dirname(filepath)); + writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); +}