refactor: consolidate MCP factory, add non-TTY support, auto-detect transcript watchers

- Phase 1: Replace 5 duplicate MCP installers with config-driven factory, extract
  shared context-injection and json-utils utilities, fix process.execPath usage
- Phase 2: Add non-TTY fallback for @clack/prompts to prevent ENOENT in CI/Docker
- Phase 3: Wire GeminiCliHooksInstaller through hook command framework with adapter
- Phase 4: Auto-start transcript watchers on worker boot when config exists

Net -107 lines via DRY consolidation of duplicated installer logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-04 00:35:55 -07:00
parent a2ac116aac
commit 2495f98496
10 changed files with 959 additions and 972 deletions
+78 -36
View File
@@ -12,6 +12,34 @@ import pc from 'picocolors';
import { execSync } from 'child_process';
import { cpSync, existsSync, readFileSync } from 'fs';
import { join } from 'path';
// Non-TTY detection: @clack/prompts crashes with ENOENT in non-TTY environments
const isInteractive = process.stdin.isTTY === true;
/** Run a list of tasks, falling back to plain console.log when non-TTY */
interface TaskDescriptor {
title: string;
task: (message: (msg: string) => void) => Promise<string>;
}
async function runTasks(tasks: TaskDescriptor[]): Promise<void> {
if (isInteractive) {
await p.tasks(tasks);
} else {
for (const t of tasks) {
const result = await t.task((msg: string) => console.log(` ${msg}`));
console.log(` ${result}`);
}
}
}
/** Log helpers that fall back to console.log in non-TTY */
const log = {
info: (msg: string) => isInteractive ? p.log.info(msg) : console.log(` ${msg}`),
success: (msg: string) => isInteractive ? p.log.success(msg) : console.log(` ${msg}`),
warn: (msg: string) => isInteractive ? p.log.warn(msg) : console.warn(` ${msg}`),
error: (msg: string) => isInteractive ? p.log.error(msg) : console.error(` ${msg}`),
};
import {
claudeSettingsPath,
ensureDirectoryExists,
@@ -23,10 +51,10 @@ import {
npmPackageRootDirectory,
pluginCacheDirectory,
pluginsDirectory,
readJsonFileSafe,
readPluginVersion,
writeJsonFileAtomic,
} from '../utils/paths.js';
import { readJsonSafe } from '../../utils/json-utils.js';
import { detectInstalledIDEs } from './ide-detection.js';
// ---------------------------------------------------------------------------
@@ -34,7 +62,7 @@ import { detectInstalledIDEs } from './ide-detection.js';
// ---------------------------------------------------------------------------
function registerMarketplace(): void {
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath());
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
knownMarketplaces['thedotmack'] = {
source: {
@@ -51,7 +79,7 @@ function registerMarketplace(): void {
}
function registerPlugin(version: string): void {
const installedPlugins = readJsonFileSafe(installedPluginsPath());
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
if (!installedPlugins.version) installedPlugins.version = 2;
if (!installedPlugins.plugins) installedPlugins.plugins = {};
@@ -73,7 +101,7 @@ function registerPlugin(version: string): void {
}
function enablePluginInClaudeSettings(): void {
const settings = readJsonFileSafe(claudeSettingsPath());
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
if (!settings.enabledPlugins) settings.enabledPlugins = {};
settings.enabledPlugins['claude-mem@thedotmack'] = true;
@@ -91,21 +119,21 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
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.');
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)`);
log.info('Cursor: hook configuration available after first launch.');
log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
break;
case 'gemini-cli': {
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
const geminiResult = await installGeminiCliHooks();
if (geminiResult === 0) {
p.log.success('Gemini CLI: hooks installed.');
log.success('Gemini CLI: hooks installed.');
} else {
p.log.error('Gemini CLI: hook installation failed.');
log.error('Gemini CLI: hook installation failed.');
}
break;
}
@@ -114,9 +142,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
const openCodeResult = await installOpenCodeIntegration();
if (openCodeResult === 0) {
p.log.success('OpenCode: plugin installed.');
log.success('OpenCode: plugin installed.');
} else {
p.log.error('OpenCode: plugin installation failed.');
log.error('OpenCode: plugin installation failed.');
}
break;
}
@@ -125,9 +153,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
const windsurfResult = await installWindsurfHooks();
if (windsurfResult === 0) {
p.log.success('Windsurf: hooks installed.');
log.success('Windsurf: hooks installed.');
} else {
p.log.error('Windsurf: hook installation failed.');
log.error('Windsurf: hook installation failed.');
}
break;
}
@@ -136,9 +164,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
const openClawResult = await installOpenClawIntegration();
if (openClawResult === 0) {
p.log.success('OpenClaw: plugin installed.');
log.success('OpenClaw: plugin installed.');
} else {
p.log.error('OpenClaw: plugin installation failed.');
log.error('OpenClaw: plugin installation failed.');
}
break;
}
@@ -147,9 +175,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
const codexResult = await installCodexCli();
if (codexResult === 0) {
p.log.success('Codex CLI: transcript watching configured.');
log.success('Codex CLI: transcript watching configured.');
} else {
p.log.error('Codex CLI: integration setup failed.');
log.error('Codex CLI: integration setup failed.');
}
break;
}
@@ -168,9 +196,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const ideInfo = allIDEs.find((i) => i.id === ideId);
const ideLabel = ideInfo?.label ?? ideId;
if (mcpResult === 0) {
p.log.success(`${ideLabel}: MCP integration installed.`);
log.success(`${ideLabel}: MCP integration installed.`);
} else {
p.log.error(`${ideLabel}: MCP integration failed.`);
log.error(`${ideLabel}: MCP integration failed.`);
}
}
break;
@@ -180,7 +208,7 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
const allIDEs = detectInstalledIDEs();
const ide = allIDEs.find((i) => i.id === ideId);
if (ide && !ide.supported) {
p.log.warn(`Support for ${ide.label} coming soon.`);
log.warn(`Support for ${ide.label} coming soon.`);
}
break;
}
@@ -197,7 +225,7 @@ async function promptForIDESelection(): Promise<string[]> {
const detected = detectedIDEs.filter((ide) => ide.detected);
if (detected.length === 0) {
p.log.warn('No supported IDEs detected. Installing for Claude Code by default.');
log.warn('No supported IDEs detected. Installing for Claude Code by default.');
return ['claude-code'];
}
@@ -295,7 +323,7 @@ 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.');
log.warn('smart-install.js not found — skipping Bun/uv auto-install.');
return;
}
@@ -305,7 +333,7 @@ function runSmartInstall(): void {
...(IS_WINDOWS ? { shell: true as const } : {}),
});
} catch {
p.log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
log.warn('smart-install encountered an issue. You may need to install Bun/uv manually.');
}
}
@@ -321,9 +349,13 @@ export interface InstallOptions {
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
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})`);
if (isInteractive) {
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
} else {
console.log('claude-mem install');
}
log.info(`Version: ${pc.cyan(version)}`);
log.info(`Platform: ${process.platform} (${process.arch})`);
// Check for existing installation
const marketplaceDir = marketplaceDirectory();
@@ -335,9 +367,9 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
const existingPluginJson = JSON.parse(
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
);
p.log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
log.warn(`Existing installation detected (v${existingPluginJson.version ?? 'unknown'}).`);
} catch {
p.log.warn('Existing installation detected.');
log.warn('Existing installation detected.');
}
if (process.stdin.isTTY) {
@@ -360,12 +392,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
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.`);
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(', ')}`);
log.error(`Unknown IDE: ${options.ide}`);
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
process.exit(1);
}
} else if (process.stdin.isTTY) {
@@ -376,7 +408,7 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
}
// Run tasks
await p.tasks([
await runTasks([
{
title: 'Copying plugin files',
task: async (message) => {
@@ -450,7 +482,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
];
p.note(summaryLines.join('\n'), 'Installation Complete');
if (isInteractive) {
p.note(summaryLines.join('\n'), 'Installation Complete');
} else {
console.log('\n Installation Complete');
summaryLines.forEach(l => console.log(` ${l}`));
}
const nextSteps = [
'Open Claude Code and start a conversation -- memory is automatic!',
@@ -459,7 +496,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
`Start worker: ${pc.bold('npx claude-mem start')}`,
];
p.note(nextSteps.join('\n'), 'Next Steps');
p.outro(pc.green('claude-mem installed successfully!'));
if (isInteractive) {
p.note(nextSteps.join('\n'), 'Next Steps');
p.outro(pc.green('claude-mem installed successfully!'));
} else {
console.log('\n Next Steps');
nextSteps.forEach(l => console.log(` ${l}`));
console.log('\nclaude-mem installed successfully!');
}
}