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:
+256
-194
File diff suppressed because one or more lines are too long
@@ -12,6 +12,34 @@ import pc from 'picocolors';
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { cpSync, existsSync, readFileSync } from 'fs';
|
import { cpSync, existsSync, readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
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 {
|
import {
|
||||||
claudeSettingsPath,
|
claudeSettingsPath,
|
||||||
ensureDirectoryExists,
|
ensureDirectoryExists,
|
||||||
@@ -23,10 +51,10 @@ import {
|
|||||||
npmPackageRootDirectory,
|
npmPackageRootDirectory,
|
||||||
pluginCacheDirectory,
|
pluginCacheDirectory,
|
||||||
pluginsDirectory,
|
pluginsDirectory,
|
||||||
readJsonFileSafe,
|
|
||||||
readPluginVersion,
|
readPluginVersion,
|
||||||
writeJsonFileAtomic,
|
writeJsonFileAtomic,
|
||||||
} from '../utils/paths.js';
|
} from '../utils/paths.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
import { detectInstalledIDEs } from './ide-detection.js';
|
import { detectInstalledIDEs } from './ide-detection.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,7 +62,7 @@ import { detectInstalledIDEs } from './ide-detection.js';
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function registerMarketplace(): void {
|
function registerMarketplace(): void {
|
||||||
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath());
|
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||||
|
|
||||||
knownMarketplaces['thedotmack'] = {
|
knownMarketplaces['thedotmack'] = {
|
||||||
source: {
|
source: {
|
||||||
@@ -51,7 +79,7 @@ function registerMarketplace(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerPlugin(version: string): 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.version) installedPlugins.version = 2;
|
||||||
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
if (!installedPlugins.plugins) installedPlugins.plugins = {};
|
||||||
@@ -73,7 +101,7 @@ function registerPlugin(version: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enablePluginInClaudeSettings(): void {
|
function enablePluginInClaudeSettings(): void {
|
||||||
const settings = readJsonFileSafe(claudeSettingsPath());
|
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||||
|
|
||||||
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
||||||
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
settings.enabledPlugins['claude-mem@thedotmack'] = true;
|
||||||
@@ -91,21 +119,21 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
case 'claude-code':
|
case 'claude-code':
|
||||||
// Claude Code picks up the plugin via marketplace registration — nothing
|
// Claude Code picks up the plugin via marketplace registration — nothing
|
||||||
// else to do beyond what registerMarketplace / registerPlugin already did.
|
// 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;
|
break;
|
||||||
|
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
p.log.info('Cursor: hook configuration available after first launch.');
|
log.info('Cursor: hook configuration available after first launch.');
|
||||||
p.log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
|
log.info(` Run: npx claude-mem cursor-setup (coming soon)`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'gemini-cli': {
|
case 'gemini-cli': {
|
||||||
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
const { installGeminiCliHooks } = await import('../../services/integrations/GeminiCliHooksInstaller.js');
|
||||||
const geminiResult = await installGeminiCliHooks();
|
const geminiResult = await installGeminiCliHooks();
|
||||||
if (geminiResult === 0) {
|
if (geminiResult === 0) {
|
||||||
p.log.success('Gemini CLI: hooks installed.');
|
log.success('Gemini CLI: hooks installed.');
|
||||||
} else {
|
} else {
|
||||||
p.log.error('Gemini CLI: hook installation failed.');
|
log.error('Gemini CLI: hook installation failed.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -114,9 +142,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
|
const { installOpenCodeIntegration } = await import('../../services/integrations/OpenCodeInstaller.js');
|
||||||
const openCodeResult = await installOpenCodeIntegration();
|
const openCodeResult = await installOpenCodeIntegration();
|
||||||
if (openCodeResult === 0) {
|
if (openCodeResult === 0) {
|
||||||
p.log.success('OpenCode: plugin installed.');
|
log.success('OpenCode: plugin installed.');
|
||||||
} else {
|
} else {
|
||||||
p.log.error('OpenCode: plugin installation failed.');
|
log.error('OpenCode: plugin installation failed.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -125,9 +153,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
const { installWindsurfHooks } = await import('../../services/integrations/WindsurfHooksInstaller.js');
|
||||||
const windsurfResult = await installWindsurfHooks();
|
const windsurfResult = await installWindsurfHooks();
|
||||||
if (windsurfResult === 0) {
|
if (windsurfResult === 0) {
|
||||||
p.log.success('Windsurf: hooks installed.');
|
log.success('Windsurf: hooks installed.');
|
||||||
} else {
|
} else {
|
||||||
p.log.error('Windsurf: hook installation failed.');
|
log.error('Windsurf: hook installation failed.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -136,9 +164,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
|
const { installOpenClawIntegration } = await import('../../services/integrations/OpenClawInstaller.js');
|
||||||
const openClawResult = await installOpenClawIntegration();
|
const openClawResult = await installOpenClawIntegration();
|
||||||
if (openClawResult === 0) {
|
if (openClawResult === 0) {
|
||||||
p.log.success('OpenClaw: plugin installed.');
|
log.success('OpenClaw: plugin installed.');
|
||||||
} else {
|
} else {
|
||||||
p.log.error('OpenClaw: plugin installation failed.');
|
log.error('OpenClaw: plugin installation failed.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -147,9 +175,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
const { installCodexCli } = await import('../../services/integrations/CodexCliInstaller.js');
|
||||||
const codexResult = await installCodexCli();
|
const codexResult = await installCodexCli();
|
||||||
if (codexResult === 0) {
|
if (codexResult === 0) {
|
||||||
p.log.success('Codex CLI: transcript watching configured.');
|
log.success('Codex CLI: transcript watching configured.');
|
||||||
} else {
|
} else {
|
||||||
p.log.error('Codex CLI: integration setup failed.');
|
log.error('Codex CLI: integration setup failed.');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -168,9 +196,9 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const ideInfo = allIDEs.find((i) => i.id === ideId);
|
const ideInfo = allIDEs.find((i) => i.id === ideId);
|
||||||
const ideLabel = ideInfo?.label ?? ideId;
|
const ideLabel = ideInfo?.label ?? ideId;
|
||||||
if (mcpResult === 0) {
|
if (mcpResult === 0) {
|
||||||
p.log.success(`${ideLabel}: MCP integration installed.`);
|
log.success(`${ideLabel}: MCP integration installed.`);
|
||||||
} else {
|
} else {
|
||||||
p.log.error(`${ideLabel}: MCP integration failed.`);
|
log.error(`${ideLabel}: MCP integration failed.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -180,7 +208,7 @@ async function setupIDEs(selectedIDEs: string[]): Promise<void> {
|
|||||||
const allIDEs = detectInstalledIDEs();
|
const allIDEs = detectInstalledIDEs();
|
||||||
const ide = allIDEs.find((i) => i.id === ideId);
|
const ide = allIDEs.find((i) => i.id === ideId);
|
||||||
if (ide && !ide.supported) {
|
if (ide && !ide.supported) {
|
||||||
p.log.warn(`Support for ${ide.label} coming soon.`);
|
log.warn(`Support for ${ide.label} coming soon.`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -197,7 +225,7 @@ async function promptForIDESelection(): Promise<string[]> {
|
|||||||
const detected = detectedIDEs.filter((ide) => ide.detected);
|
const detected = detectedIDEs.filter((ide) => ide.detected);
|
||||||
|
|
||||||
if (detected.length === 0) {
|
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'];
|
return ['claude-code'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +323,7 @@ function runSmartInstall(): void {
|
|||||||
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
|
const smartInstallPath = join(marketplaceDirectory(), 'plugin', 'scripts', 'smart-install.js');
|
||||||
|
|
||||||
if (!existsSync(smartInstallPath)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +333,7 @@ function runSmartInstall(): void {
|
|||||||
...(IS_WINDOWS ? { shell: true as const } : {}),
|
...(IS_WINDOWS ? { shell: true as const } : {}),
|
||||||
});
|
});
|
||||||
} catch {
|
} 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> {
|
export async function runInstallCommand(options: InstallOptions = {}): Promise<void> {
|
||||||
const version = readPluginVersion();
|
const version = readPluginVersion();
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
|
p.intro(pc.bgCyan(pc.black(' claude-mem install ')));
|
||||||
p.log.info(`Version: ${pc.cyan(version)}`);
|
} else {
|
||||||
p.log.info(`Platform: ${process.platform} (${process.arch})`);
|
console.log('claude-mem install');
|
||||||
|
}
|
||||||
|
log.info(`Version: ${pc.cyan(version)}`);
|
||||||
|
log.info(`Platform: ${process.platform} (${process.arch})`);
|
||||||
|
|
||||||
// Check for existing installation
|
// Check for existing installation
|
||||||
const marketplaceDir = marketplaceDirectory();
|
const marketplaceDir = marketplaceDirectory();
|
||||||
@@ -335,9 +367,9 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
|||||||
const existingPluginJson = JSON.parse(
|
const existingPluginJson = JSON.parse(
|
||||||
readFileSync(join(marketplaceDir, 'plugin', '.claude-plugin', 'plugin.json'), 'utf-8'),
|
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 {
|
} catch {
|
||||||
p.log.warn('Existing installation detected.');
|
log.warn('Existing installation detected.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
@@ -360,12 +392,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
|||||||
const allIDEs = detectInstalledIDEs();
|
const allIDEs = detectInstalledIDEs();
|
||||||
const match = allIDEs.find((i) => i.id === options.ide);
|
const match = allIDEs.find((i) => i.id === options.ide);
|
||||||
if (match && !match.supported) {
|
if (match && !match.supported) {
|
||||||
p.log.error(`Support for ${match.label} coming soon.`);
|
log.error(`Support for ${match.label} coming soon.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!match) {
|
if (!match) {
|
||||||
p.log.error(`Unknown IDE: ${options.ide}`);
|
log.error(`Unknown IDE: ${options.ide}`);
|
||||||
p.log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
|
log.info(`Available IDEs: ${allIDEs.map((i) => i.id).join(', ')}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else if (process.stdin.isTTY) {
|
} else if (process.stdin.isTTY) {
|
||||||
@@ -376,7 +408,7 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run tasks
|
// Run tasks
|
||||||
await p.tasks([
|
await runTasks([
|
||||||
{
|
{
|
||||||
title: 'Copying plugin files',
|
title: 'Copying plugin files',
|
||||||
task: async (message) => {
|
task: async (message) => {
|
||||||
@@ -450,7 +482,12 @@ export async function runInstallCommand(options: InstallOptions = {}): Promise<v
|
|||||||
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
|
`IDEs: ${pc.cyan(selectedIDEs.join(', '))}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
p.note(summaryLines.join('\n'), 'Installation Complete');
|
p.note(summaryLines.join('\n'), 'Installation Complete');
|
||||||
|
} else {
|
||||||
|
console.log('\n Installation Complete');
|
||||||
|
summaryLines.forEach(l => console.log(` ${l}`));
|
||||||
|
}
|
||||||
|
|
||||||
const nextSteps = [
|
const nextSteps = [
|
||||||
'Open Claude Code and start a conversation -- memory is automatic!',
|
'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')}`,
|
`Start worker: ${pc.bold('npx claude-mem start')}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isInteractive) {
|
||||||
p.note(nextSteps.join('\n'), 'Next Steps');
|
p.note(nextSteps.join('\n'), 'Next Steps');
|
||||||
|
|
||||||
p.outro(pc.green('claude-mem installed successfully!'));
|
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!');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
knownMarketplacesPath,
|
knownMarketplacesPath,
|
||||||
marketplaceDirectory,
|
marketplaceDirectory,
|
||||||
pluginsDirectory,
|
pluginsDirectory,
|
||||||
readJsonFileSafe,
|
|
||||||
writeJsonFileAtomic,
|
writeJsonFileAtomic,
|
||||||
} from '../utils/paths.js';
|
} from '../utils/paths.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Cleanup helpers
|
// Cleanup helpers
|
||||||
@@ -45,7 +45,7 @@ function removeCacheDirectory(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFromKnownMarketplaces(): void {
|
function removeFromKnownMarketplaces(): void {
|
||||||
const knownMarketplaces = readJsonFileSafe(knownMarketplacesPath());
|
const knownMarketplaces = readJsonSafe<Record<string, any>>(knownMarketplacesPath(), {});
|
||||||
if (knownMarketplaces['thedotmack']) {
|
if (knownMarketplaces['thedotmack']) {
|
||||||
delete knownMarketplaces['thedotmack'];
|
delete knownMarketplaces['thedotmack'];
|
||||||
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
writeJsonFileAtomic(knownMarketplacesPath(), knownMarketplaces);
|
||||||
@@ -53,7 +53,7 @@ function removeFromKnownMarketplaces(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFromInstalledPlugins(): void {
|
function removeFromInstalledPlugins(): void {
|
||||||
const installedPlugins = readJsonFileSafe(installedPluginsPath());
|
const installedPlugins = readJsonSafe<Record<string, any>>(installedPluginsPath(), {});
|
||||||
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
|
if (installedPlugins.plugins?.['claude-mem@thedotmack']) {
|
||||||
delete installedPlugins.plugins['claude-mem@thedotmack'];
|
delete installedPlugins.plugins['claude-mem@thedotmack'];
|
||||||
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
writeJsonFileAtomic(installedPluginsPath(), installedPlugins);
|
||||||
@@ -61,7 +61,7 @@ function removeFromInstalledPlugins(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFromClaudeSettings(): void {
|
function removeFromClaudeSettings(): void {
|
||||||
const settings = readJsonFileSafe(claudeSettingsPath());
|
const settings = readJsonSafe<Record<string, any>>(claudeSettingsPath(), {});
|
||||||
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
|
||||||
delete settings.enabledPlugins['claude-mem@thedotmack'];
|
delete settings.enabledPlugins['claude-mem@thedotmack'];
|
||||||
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
writeJsonFileAtomic(claudeSettingsPath(), settings);
|
||||||
|
|||||||
@@ -137,14 +137,11 @@ export function ensureDirectoryExists(directoryPath: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readJsonFileSafe(filepath: string): any {
|
/**
|
||||||
if (!existsSync(filepath)) return {};
|
* @deprecated Use `readJsonSafe` from `../../utils/json-utils.js` instead.
|
||||||
try {
|
* Kept as re-export for backward compatibility.
|
||||||
return JSON.parse(readFileSync(filepath, 'utf-8'));
|
*/
|
||||||
} catch {
|
export { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
export function writeJsonFileAtomic(filepath: string, data: any): void {
|
||||||
ensureDirectoryExists(dirname(filepath));
|
ensureDirectoryExists(dirname(filepath));
|
||||||
|
|||||||
@@ -1,148 +1,270 @@
|
|||||||
/**
|
/**
|
||||||
* GeminiCliHooksInstaller - First-class Gemini CLI integration for claude-mem
|
* GeminiCliHooksInstaller - Gemini CLI integration for claude-mem
|
||||||
*
|
*
|
||||||
* Installs claude-mem hooks into ~/.gemini/settings.json using deep merge
|
* Installs hooks into ~/.gemini/settings.json using the unified CLI:
|
||||||
* to preserve any existing user configuration.
|
* bun worker-service.cjs hook gemini-cli <event>
|
||||||
*
|
*
|
||||||
* Gemini CLI hook config format:
|
* This routes through the hook-command.ts framework:
|
||||||
|
* readJsonFromStdin() → gemini-cli adapter → event handler → POST to worker
|
||||||
|
*
|
||||||
|
* Gemini CLI supports 11 lifecycle hooks; we register 8 that map to
|
||||||
|
* useful memory events. See src/cli/adapters/gemini-cli.ts for the
|
||||||
|
* adapter that normalizes Gemini's stdin JSON to NormalizedHookInput.
|
||||||
|
*
|
||||||
|
* Hook config format (verified against Gemini CLI source):
|
||||||
* {
|
* {
|
||||||
* "hooks": {
|
* "hooks": {
|
||||||
* "AfterTool": [{
|
* "AfterTool": [{
|
||||||
* "matcher": "*",
|
* "matcher": "*",
|
||||||
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000, "description": "..." }]
|
* "hooks": [{ "name": "claude-mem", "type": "command", "command": "...", "timeout": 5000 }]
|
||||||
* }]
|
* }]
|
||||||
* }
|
* }
|
||||||
* }
|
* }
|
||||||
*
|
|
||||||
* Registers 8 of 11 Gemini CLI hooks:
|
|
||||||
* SessionStart — inject memory context (via hookSpecificOutput.additionalContext)
|
|
||||||
* BeforeAgent — capture user prompt
|
|
||||||
* AfterAgent — capture full agent response
|
|
||||||
* BeforeTool — capture tool intent before execution
|
|
||||||
* AfterTool — capture all tool results (matcher: "*")
|
|
||||||
* Notification — capture system events (ToolPermission, etc.)
|
|
||||||
* PreCompress — trigger summary generation
|
|
||||||
* SessionEnd — finalize session
|
|
||||||
*
|
|
||||||
* Skipped (model-level, too chatty):
|
|
||||||
* BeforeModel, AfterModel, BeforeToolSelection
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { replaceTaggedContent } from '../../utils/claude-md-utils.js';
|
import { findWorkerServicePath, findBunPath } from './CursorHooksInstaller.js';
|
||||||
import { findBunPath, findWorkerServicePath } from './CursorHooksInstaller.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ============================================================================
|
||||||
|
|
||||||
|
/** A single hook entry in a Gemini CLI hook group */
|
||||||
interface GeminiHookEntry {
|
interface GeminiHookEntry {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'command';
|
type: 'command';
|
||||||
command: string;
|
command: string;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
description?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeminiHookMatcher {
|
/** A hook group — matcher selects which tools/events this applies to */
|
||||||
|
interface GeminiHookGroup {
|
||||||
matcher: string;
|
matcher: string;
|
||||||
hooks: GeminiHookEntry[];
|
hooks: GeminiHookEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GeminiSettingsJson {
|
/** The hooks section in ~/.gemini/settings.json */
|
||||||
hooks?: Record<string, GeminiHookMatcher[]>;
|
interface GeminiHooksConfig {
|
||||||
[otherKeys: string]: unknown;
|
[eventName: string]: GeminiHookGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/** Full ~/.gemini/settings.json structure (partial — we only care about hooks) */
|
||||||
// Constants
|
interface GeminiSettingsJson {
|
||||||
// ---------------------------------------------------------------------------
|
hooks?: GeminiHooksConfig;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const GEMINI_CONFIG_DIR = path.join(homedir(), '.gemini');
|
||||||
|
const GEMINI_SETTINGS_PATH = path.join(GEMINI_CONFIG_DIR, 'settings.json');
|
||||||
|
const GEMINI_MD_PATH = path.join(GEMINI_CONFIG_DIR, 'GEMINI.md');
|
||||||
|
|
||||||
const GEMINI_DIR = path.join(homedir(), '.gemini');
|
|
||||||
const GEMINI_SETTINGS_PATH = path.join(GEMINI_DIR, 'settings.json');
|
|
||||||
const GEMINI_MD_PATH = path.join(GEMINI_DIR, 'GEMINI.md');
|
|
||||||
const HOOK_NAME = 'claude-mem';
|
const HOOK_NAME = 'claude-mem';
|
||||||
const HOOK_TIMEOUT_MS = 5000;
|
const HOOK_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini CLI events → claude-mem internal events.
|
* Mapping from Gemini CLI hook events to internal claude-mem event types.
|
||||||
*
|
*
|
||||||
* We register 8 of 11 hooks. Skipped: BeforeModel, AfterModel, BeforeToolSelection
|
* These events are processed by hookCommand() in src/cli/hook-command.ts,
|
||||||
* (model-level events fire per-LLM-call — too chatty for observation capture).
|
* which reads stdin via readJsonFromStdin(), normalizes through the
|
||||||
|
* gemini-cli adapter, and dispatches to the matching event handler.
|
||||||
|
*
|
||||||
|
* Events NOT mapped (too chatty for memory capture):
|
||||||
|
* BeforeModel, AfterModel, BeforeToolSelection
|
||||||
*/
|
*/
|
||||||
interface GeminiEventConfig {
|
const GEMINI_EVENT_TO_INTERNAL_EVENT: Record<string, string> = {
|
||||||
claudeMemEvent: string;
|
'SessionStart': 'context',
|
||||||
description: string;
|
'BeforeAgent': 'user-message',
|
||||||
}
|
'AfterAgent': 'observation',
|
||||||
|
'BeforeTool': 'observation',
|
||||||
const GEMINI_EVENTS: Record<string, GeminiEventConfig> = {
|
'AfterTool': 'observation',
|
||||||
'SessionStart': { claudeMemEvent: 'context', description: 'Inject memory context from past sessions' },
|
'PreCompress': 'summarize',
|
||||||
'BeforeAgent': { claudeMemEvent: 'session-init', description: 'Initialize session and capture user prompt' },
|
'Notification': 'observation',
|
||||||
'AfterAgent': { claudeMemEvent: 'observation', description: 'Capture full agent response' },
|
'SessionEnd': 'session-complete',
|
||||||
'BeforeTool': { claudeMemEvent: 'observation', description: 'Capture tool intent before execution' },
|
|
||||||
'AfterTool': { claudeMemEvent: 'observation', description: 'Capture tool results after execution' },
|
|
||||||
'Notification': { claudeMemEvent: 'observation', description: 'Capture system events (permissions, etc.)' },
|
|
||||||
'PreCompress': { claudeMemEvent: 'summarize', description: 'Generate session summary before compression' },
|
|
||||||
'SessionEnd': { claudeMemEvent: 'session-complete', description: 'Finalize session and persist memory' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ============================================================================
|
||||||
// Deep Merge for Hook Arrays
|
// Hook Command Builder
|
||||||
// ---------------------------------------------------------------------------
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge claude-mem hooks into an existing event's hook matcher array.
|
|
||||||
* If a matcher with the same `matcher` value already has a hook named "claude-mem",
|
|
||||||
* it is replaced. Otherwise, the hook is appended.
|
|
||||||
*/
|
|
||||||
function mergeHookMatchers(
|
|
||||||
existingMatchers: GeminiHookMatcher[],
|
|
||||||
newMatcher: GeminiHookMatcher,
|
|
||||||
): GeminiHookMatcher[] {
|
|
||||||
const result = [...existingMatchers];
|
|
||||||
|
|
||||||
const existingMatcherIndex = result.findIndex(
|
|
||||||
(m) => m.matcher === newMatcher.matcher,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingMatcherIndex !== -1) {
|
|
||||||
// Matcher exists — replace or add our hook within it
|
|
||||||
const existing = result[existingMatcherIndex];
|
|
||||||
const hookIndex = existing.hooks.findIndex((h) => h.name === HOOK_NAME);
|
|
||||||
if (hookIndex !== -1) {
|
|
||||||
existing.hooks[hookIndex] = newMatcher.hooks[0];
|
|
||||||
} else {
|
|
||||||
existing.hooks.push(newMatcher.hooks[0]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No matching matcher — add the whole entry
|
|
||||||
result.push(newMatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hook Installation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the hook command string for a given Gemini CLI event.
|
* Build the hook command string for a given Gemini CLI event.
|
||||||
*
|
*
|
||||||
* Invokes: <bun-path> <worker-service.cjs> hook gemini-cli <event>
|
* The command invokes worker-service.cjs with the `hook` subcommand,
|
||||||
|
* which delegates to hookCommand('gemini-cli', event) — the same
|
||||||
|
* framework used by Claude Code and Cursor hooks.
|
||||||
|
*
|
||||||
|
* Pipeline: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
* → worker-service.ts parses args, ensures worker daemon is running
|
||||||
|
* → hookCommand('gemini-cli', '<event>')
|
||||||
|
* → readJsonFromStdin() reads Gemini's JSON payload
|
||||||
|
* → geminiCliAdapter.normalizeInput() → NormalizedHookInput
|
||||||
|
* → eventHandler.execute(input)
|
||||||
|
* → geminiCliAdapter.formatOutput(result)
|
||||||
|
* → JSON.stringify to stdout
|
||||||
*/
|
*/
|
||||||
function buildHookCommand(bunPath: string, workerServicePath: string, claudeMemEvent: string): string {
|
function buildHookCommand(
|
||||||
|
bunPath: string,
|
||||||
|
workerServicePath: string,
|
||||||
|
geminiEventName: string,
|
||||||
|
): string {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[geminiEventName];
|
||||||
|
if (!internalEvent) {
|
||||||
|
throw new Error(`Unknown Gemini CLI event: ${geminiEventName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape backslashes for JSON compatibility on Windows
|
||||||
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
|
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
|
||||||
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
|
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
|
||||||
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${claudeMemEvent}`;
|
|
||||||
|
return `"${escapedBunPath}" "${escapedWorkerPath}" hook gemini-cli ${internalEvent}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install claude-mem hooks into Gemini CLI's settings.json.
|
* Create a hook group entry for a Gemini CLI event.
|
||||||
* Deep-merges with existing configuration — never overwrites.
|
* Uses matcher "*" to match all tools/contexts for that event.
|
||||||
|
*/
|
||||||
|
function createHookGroup(hookCommand: string): GeminiHookGroup {
|
||||||
|
return {
|
||||||
|
matcher: '*',
|
||||||
|
hooks: [{
|
||||||
|
name: HOOK_NAME,
|
||||||
|
type: 'command',
|
||||||
|
command: hookCommand,
|
||||||
|
timeout: HOOK_TIMEOUT_MS,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings JSON Management
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ~/.gemini/settings.json, returning empty object if missing/corrupt.
|
||||||
|
*/
|
||||||
|
function readGeminiSettings(): GeminiSettingsJson {
|
||||||
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(GEMINI_SETTINGS_PATH, 'utf-8');
|
||||||
|
return JSON.parse(content) as GeminiSettingsJson;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('GEMINI', `Failed to parse ${GEMINI_SETTINGS_PATH}, treating as empty`, {});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write settings back to ~/.gemini/settings.json.
|
||||||
|
* Creates the directory if it doesn't exist.
|
||||||
|
*/
|
||||||
|
function writeGeminiSettings(settings: GeminiSettingsJson): void {
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merge claude-mem hooks into existing settings.
|
||||||
|
*
|
||||||
|
* For each event:
|
||||||
|
* - If the event already has a hook group with a claude-mem hook, update it
|
||||||
|
* - Otherwise, append a new hook group
|
||||||
|
*
|
||||||
|
* Preserves all non-claude-mem hooks and all non-hook settings.
|
||||||
|
*/
|
||||||
|
function mergeHooksIntoSettings(
|
||||||
|
existingSettings: GeminiSettingsJson,
|
||||||
|
newHooks: GeminiHooksConfig,
|
||||||
|
): GeminiSettingsJson {
|
||||||
|
const settings = { ...existingSettings };
|
||||||
|
if (!settings.hooks) {
|
||||||
|
settings.hooks = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [eventName, newGroups] of Object.entries(newHooks)) {
|
||||||
|
const existingGroups: GeminiHookGroup[] = settings.hooks[eventName] ?? [];
|
||||||
|
|
||||||
|
// For each new hook group, check if there's already a group
|
||||||
|
// containing a claude-mem hook — update it in place
|
||||||
|
for (const newGroup of newGroups) {
|
||||||
|
const existingGroupIndex = existingGroups.findIndex((group: GeminiHookGroup) =>
|
||||||
|
group.hooks.some((hook: GeminiHookEntry) => hook.name === HOOK_NAME)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingGroupIndex >= 0) {
|
||||||
|
// Update existing group: replace the claude-mem hook entry
|
||||||
|
const existingGroup: GeminiHookGroup = existingGroups[existingGroupIndex];
|
||||||
|
const hookIndex = existingGroup.hooks.findIndex((hook: GeminiHookEntry) => hook.name === HOOK_NAME);
|
||||||
|
if (hookIndex >= 0) {
|
||||||
|
existingGroup.hooks[hookIndex] = newGroup.hooks[0];
|
||||||
|
} else {
|
||||||
|
existingGroup.hooks.push(newGroup.hooks[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No existing claude-mem group — append
|
||||||
|
existingGroups.push(newGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.hooks[eventName] = existingGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GEMINI.md Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append or update the claude-mem context section in ~/.gemini/GEMINI.md.
|
||||||
|
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
|
||||||
|
*/
|
||||||
|
function setupGeminiMdContextSection(): void {
|
||||||
|
const contextTag = '<claude-mem-context>';
|
||||||
|
const contextEndTag = '</claude-mem-context>';
|
||||||
|
const placeholder = `${contextTag}
|
||||||
|
# Memory Context from Past Sessions
|
||||||
|
|
||||||
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
|
${contextEndTag}`;
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
content = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes(contextTag)) {
|
||||||
|
// Already has claude-mem section — leave it alone (may have real context)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the section
|
||||||
|
const separator = content.length > 0 && !content.endsWith('\n') ? '\n\n' : content.length > 0 ? '\n' : '';
|
||||||
|
const newContent = content + separator + placeholder + '\n';
|
||||||
|
|
||||||
|
mkdirSync(GEMINI_CONFIG_DIR, { recursive: true });
|
||||||
|
writeFileSync(GEMINI_MD_PATH, newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install claude-mem hooks into ~/.gemini/settings.json.
|
||||||
|
*
|
||||||
|
* Merges hooks non-destructively: existing settings and non-claude-mem
|
||||||
|
* hooks are preserved. Existing claude-mem hooks are updated in place.
|
||||||
*
|
*
|
||||||
* @returns 0 on success, 1 on failure
|
* @returns 0 on success, 1 on failure
|
||||||
*/
|
*/
|
||||||
@@ -162,80 +284,47 @@ export async function installGeminiCliHooks(): Promise<number> {
|
|||||||
console.log(` Worker service: ${workerServicePath}`);
|
console.log(` Worker service: ${workerServicePath}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure ~/.gemini exists
|
// Build hook commands for all mapped events
|
||||||
mkdirSync(GEMINI_DIR, { recursive: true });
|
const hooksConfig: GeminiHooksConfig = {};
|
||||||
|
for (const geminiEvent of Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT)) {
|
||||||
// Read existing settings (deep merge, never overwrite)
|
const command = buildHookCommand(bunPath, workerServicePath, geminiEvent);
|
||||||
let settings: GeminiSettingsJson = {};
|
hooksConfig[geminiEvent] = [createHookGroup(command)];
|
||||||
if (existsSync(GEMINI_SETTINGS_PATH)) {
|
|
||||||
try {
|
|
||||||
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
|
|
||||||
} catch (parseError) {
|
|
||||||
logger.error('GEMINI', 'Corrupt settings.json, creating backup', { path: GEMINI_SETTINGS_PATH }, parseError as Error);
|
|
||||||
// Back up corrupt file
|
|
||||||
const backupPath = `${GEMINI_SETTINGS_PATH}.backup.${Date.now()}`;
|
|
||||||
writeFileSync(backupPath, readFileSync(GEMINI_SETTINGS_PATH));
|
|
||||||
console.warn(` Backed up corrupt settings.json to ${backupPath}`);
|
|
||||||
settings = {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize hooks object if missing
|
// Read existing settings and merge
|
||||||
if (!settings.hooks) {
|
const existingSettings = readGeminiSettings();
|
||||||
settings.hooks = {};
|
const mergedSettings = mergeHooksIntoSettings(existingSettings, hooksConfig);
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
writeGeminiSettings(mergedSettings);
|
||||||
|
console.log(` Merged hooks into ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
|
// Setup GEMINI.md context injection
|
||||||
|
setupGeminiMdContextSection();
|
||||||
|
console.log(` Setup context injection in ${GEMINI_MD_PATH}`);
|
||||||
|
|
||||||
|
// List installed events
|
||||||
|
const eventNames = Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT);
|
||||||
|
console.log(` Registered ${eventNames.length} hook events:`);
|
||||||
|
for (const event of eventNames) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event];
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register each event
|
|
||||||
for (const [geminiEvent, config] of Object.entries(GEMINI_EVENTS)) {
|
|
||||||
const command = buildHookCommand(bunPath, workerServicePath, config.claudeMemEvent);
|
|
||||||
|
|
||||||
const newMatcher: GeminiHookMatcher = {
|
|
||||||
matcher: '*',
|
|
||||||
hooks: [{
|
|
||||||
name: HOOK_NAME,
|
|
||||||
type: 'command',
|
|
||||||
command,
|
|
||||||
timeout: HOOK_TIMEOUT_MS,
|
|
||||||
description: config.description,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingMatchers = settings.hooks[geminiEvent] ?? [];
|
|
||||||
settings.hooks[geminiEvent] = mergeHookMatchers(existingMatchers, newMatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write merged settings
|
|
||||||
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
||||||
console.log(` Updated ${GEMINI_SETTINGS_PATH}`);
|
|
||||||
console.log(` Registered hooks for: ${Object.keys(GEMINI_EVENTS).join(', ')}`);
|
|
||||||
|
|
||||||
// Inject context into GEMINI.md
|
|
||||||
injectGeminiMdContext();
|
|
||||||
|
|
||||||
console.log(`
|
console.log(`
|
||||||
Installation complete! (8 hooks registered)
|
Installation complete!
|
||||||
|
|
||||||
Hooks installed to: ${GEMINI_SETTINGS_PATH}
|
Hooks installed to: ${GEMINI_SETTINGS_PATH}
|
||||||
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
|
Using unified CLI: bun worker-service.cjs hook gemini-cli <event>
|
||||||
|
|
||||||
Registered hooks:
|
|
||||||
SessionStart → Inject memory context from past sessions
|
|
||||||
BeforeAgent → Capture user prompt for memory
|
|
||||||
AfterAgent → Capture full agent response
|
|
||||||
BeforeTool → Capture tool intent before execution
|
|
||||||
AfterTool → Capture tool results after execution
|
|
||||||
Notification → Capture system events (permissions, etc.)
|
|
||||||
PreCompress → Generate session summary before compression
|
|
||||||
SessionEnd → Finalize session and persist memory
|
|
||||||
|
|
||||||
Next steps:
|
Next steps:
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
1. Start claude-mem worker: claude-mem start
|
||||||
2. Restart Gemini CLI to load the hooks
|
2. Restart Gemini CLI to load the hooks
|
||||||
3. Memory capture is now automatic!
|
3. Memory will be captured automatically during sessions
|
||||||
|
|
||||||
Context Injection:
|
Context Injection:
|
||||||
Memory from past sessions is injected via hookSpecificOutput.additionalContext
|
Context from past sessions is injected via ~/.gemini/GEMINI.md
|
||||||
on SessionStart, and persisted in ${GEMINI_MD_PATH} for static context.
|
and automatically included in Gemini CLI conversations.
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
@@ -245,48 +334,10 @@ Context Injection:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Context Injection (GEMINI.md)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject claude-mem context section into ~/.gemini/GEMINI.md.
|
* Uninstall claude-mem hooks from ~/.gemini/settings.json.
|
||||||
* Uses the same <claude-mem-context> tag pattern as CLAUDE.md.
|
*
|
||||||
* Preserves any existing user content outside the tags.
|
* Removes only claude-mem hooks — other hooks and settings are preserved.
|
||||||
*/
|
|
||||||
function injectGeminiMdContext(): void {
|
|
||||||
try {
|
|
||||||
let existingContent = '';
|
|
||||||
if (existsSync(GEMINI_MD_PATH)) {
|
|
||||||
existingContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial placeholder content — will be populated after first session
|
|
||||||
const contextContent = [
|
|
||||||
'# Recent Activity',
|
|
||||||
'',
|
|
||||||
'<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->',
|
|
||||||
'',
|
|
||||||
'*No context yet. Complete your first session and context will appear here.*',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const finalContent = replaceTaggedContent(existingContent, contextContent);
|
|
||||||
writeFileSync(GEMINI_MD_PATH, finalContent);
|
|
||||||
console.log(` Injected context placeholder into ${GEMINI_MD_PATH}`);
|
|
||||||
} catch (error) {
|
|
||||||
// Non-fatal — hooks still work without context injection
|
|
||||||
logger.warn('GEMINI', 'Failed to inject GEMINI.md context', { error: (error as Error).message });
|
|
||||||
console.warn(` Warning: Could not inject context into GEMINI.md: ${(error as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Uninstallation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove claude-mem hooks from Gemini CLI settings.json.
|
|
||||||
* Preserves all other hooks and settings.
|
|
||||||
*
|
*
|
||||||
* @returns 0 on success, 1 on failure
|
* @returns 0 on success, 1 on failure
|
||||||
*/
|
*/
|
||||||
@@ -295,44 +346,31 @@ export function uninstallGeminiCliHooks(): number {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
console.log(' No settings.json found — nothing to uninstall.');
|
console.log(' No Gemini CLI settings found — nothing to uninstall.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings: GeminiSettingsJson;
|
const settings = readGeminiSettings();
|
||||||
try {
|
|
||||||
settings = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
|
|
||||||
} catch {
|
|
||||||
console.error(' Could not parse settings.json');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settings.hooks) {
|
if (!settings.hooks) {
|
||||||
console.log(' No hooks configured — nothing to uninstall.');
|
console.log(' No hooks found in Gemini CLI settings — nothing to uninstall.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let removedCount = 0;
|
let removedCount = 0;
|
||||||
|
|
||||||
// Remove claude-mem hooks from each event
|
// Remove claude-mem hooks from each event
|
||||||
for (const eventName of Object.keys(settings.hooks)) {
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
const matchers = settings.hooks[eventName];
|
const filteredGroups = groups.filter(group =>
|
||||||
if (!Array.isArray(matchers)) continue;
|
!group.hooks.some(hook => hook.name === HOOK_NAME)
|
||||||
|
|
||||||
for (const matcher of matchers) {
|
|
||||||
if (!Array.isArray(matcher.hooks)) continue;
|
|
||||||
const beforeLength = matcher.hooks.length;
|
|
||||||
matcher.hooks = matcher.hooks.filter((h) => h.name !== HOOK_NAME);
|
|
||||||
removedCount += beforeLength - matcher.hooks.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up empty matchers
|
|
||||||
settings.hooks[eventName] = matchers.filter(
|
|
||||||
(m) => m.hooks.length > 0,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clean up empty event arrays
|
if (filteredGroups.length < groups.length) {
|
||||||
if (settings.hooks[eventName].length === 0) {
|
removedCount += groups.length - filteredGroups.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredGroups.length > 0) {
|
||||||
|
settings.hooks[eventName] = filteredGroups;
|
||||||
|
} else {
|
||||||
delete settings.hooks[eventName];
|
delete settings.hooks[eventName];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,15 +380,22 @@ export function uninstallGeminiCliHooks(): number {
|
|||||||
delete settings.hooks;
|
delete settings.hooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(GEMINI_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
writeGeminiSettings(settings);
|
||||||
console.log(` Removed ${removedCount} claude-mem hook(s) from settings.json`);
|
console.log(` Removed ${removedCount} claude-mem hook group(s) from ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
|
||||||
// Remove context section from GEMINI.md
|
// Remove claude-mem context section from GEMINI.md
|
||||||
removeGeminiMdContext();
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
|
let mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
console.log('\nUninstallation complete!');
|
const contextRegex = /\n?<claude-mem-context>[\s\S]*?<\/claude-mem-context>\n?/;
|
||||||
console.log('Restart Gemini CLI to apply changes.\n');
|
if (contextRegex.test(mdContent)) {
|
||||||
|
mdContent = mdContent.replace(contextRegex, '');
|
||||||
|
writeFileSync(GEMINI_MD_PATH, mdContent);
|
||||||
|
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nUninstallation complete!\n');
|
||||||
|
console.log('Restart Gemini CLI to apply changes.');
|
||||||
return 0;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
console.error(`\nUninstallation failed: ${(error as Error).message}`);
|
||||||
@@ -358,46 +403,6 @@ export function uninstallGeminiCliHooks(): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove claude-mem context section from GEMINI.md.
|
|
||||||
* Preserves user content outside the <claude-mem-context> tags.
|
|
||||||
*/
|
|
||||||
function removeGeminiMdContext(): void {
|
|
||||||
try {
|
|
||||||
if (!existsSync(GEMINI_MD_PATH)) return;
|
|
||||||
|
|
||||||
const content = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
|
||||||
const startTag = '<claude-mem-context>';
|
|
||||||
const endTag = '</claude-mem-context>';
|
|
||||||
|
|
||||||
const startIdx = content.indexOf(startTag);
|
|
||||||
const endIdx = content.indexOf(endTag);
|
|
||||||
|
|
||||||
if (startIdx === -1 || endIdx === -1) return;
|
|
||||||
|
|
||||||
// Remove the tagged section and any surrounding blank lines
|
|
||||||
const before = content.substring(0, startIdx).replace(/\n+$/, '');
|
|
||||||
const after = content.substring(endIdx + endTag.length).replace(/^\n+/, '');
|
|
||||||
const finalContent = (before + (after ? '\n\n' + after : '')).trim();
|
|
||||||
|
|
||||||
if (finalContent) {
|
|
||||||
writeFileSync(GEMINI_MD_PATH, finalContent + '\n');
|
|
||||||
} else {
|
|
||||||
// File would be empty — leave it empty rather than deleting
|
|
||||||
// (user may have other tooling that expects it to exist)
|
|
||||||
writeFileSync(GEMINI_MD_PATH, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(` Removed context section from ${GEMINI_MD_PATH}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('GEMINI', 'Failed to clean GEMINI.md context', { error: (error as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Status Check
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check Gemini CLI hooks installation status.
|
* Check Gemini CLI hooks installation status.
|
||||||
*
|
*
|
||||||
@@ -407,64 +412,93 @@ export function checkGeminiCliHooksStatus(): number {
|
|||||||
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
|
console.log('\nClaude-Mem Gemini CLI Hooks Status\n');
|
||||||
|
|
||||||
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
if (!existsSync(GEMINI_SETTINGS_PATH)) {
|
||||||
console.log('Status: Not installed');
|
console.log('Gemini CLI settings: Not found');
|
||||||
console.log(` No settings file at ${GEMINI_SETTINGS_PATH}`);
|
console.log(` Expected at: ${GEMINI_SETTINGS_PATH}\n`);
|
||||||
console.log('\nRun: npx claude-mem install --ide gemini-cli\n');
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const settings = readGeminiSettings();
|
||||||
const settings: GeminiSettingsJson = JSON.parse(readFileSync(GEMINI_SETTINGS_PATH, 'utf-8'));
|
|
||||||
|
|
||||||
if (!settings.hooks) {
|
if (!settings.hooks) {
|
||||||
console.log('Status: Not installed');
|
console.log('Gemini CLI settings: Found, but no hooks configured\n');
|
||||||
console.log(' settings.json exists but has no hooks section.');
|
console.log('No hooks installed. Run: claude-mem install --ide gemini-cli\n');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for claude-mem hooks
|
||||||
const installedEvents: string[] = [];
|
const installedEvents: string[] = [];
|
||||||
for (const [eventName, matchers] of Object.entries(settings.hooks)) {
|
for (const [eventName, groups] of Object.entries(settings.hooks)) {
|
||||||
if (!Array.isArray(matchers)) continue;
|
const hasClaudeMem = groups.some(group =>
|
||||||
for (const matcher of matchers) {
|
group.hooks.some(hook => hook.name === HOOK_NAME)
|
||||||
if (matcher.hooks?.some((h: GeminiHookEntry) => h.name === HOOK_NAME)) {
|
);
|
||||||
|
if (hasClaudeMem) {
|
||||||
installedEvents.push(eventName);
|
installedEvents.push(eventName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (installedEvents.length === 0) {
|
if (installedEvents.length === 0) {
|
||||||
console.log('Status: Not installed');
|
console.log('Gemini CLI settings: Found, but no claude-mem hooks\n');
|
||||||
console.log(' settings.json exists but no claude-mem hooks found.');
|
console.log('Run: claude-mem install --ide gemini-cli\n');
|
||||||
} else {
|
return 0;
|
||||||
console.log('Status: Installed');
|
}
|
||||||
console.log(` Config: ${GEMINI_SETTINGS_PATH}`);
|
|
||||||
console.log(` Events: ${installedEvents.join(', ')}`);
|
console.log(`Settings: ${GEMINI_SETTINGS_PATH}`);
|
||||||
|
console.log(`Mode: Unified CLI (bun worker-service.cjs hook gemini-cli)`);
|
||||||
|
console.log(`Events: ${installedEvents.length} of ${Object.keys(GEMINI_EVENT_TO_INTERNAL_EVENT).length} mapped`);
|
||||||
|
for (const event of installedEvents) {
|
||||||
|
const internalEvent = GEMINI_EVENT_TO_INTERNAL_EVENT[event] ?? 'unknown';
|
||||||
|
console.log(` ${event} → ${internalEvent}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Check GEMINI.md context
|
// Check GEMINI.md context
|
||||||
if (existsSync(GEMINI_MD_PATH)) {
|
if (existsSync(GEMINI_MD_PATH)) {
|
||||||
const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
const mdContent = readFileSync(GEMINI_MD_PATH, 'utf-8');
|
||||||
if (mdContent.includes('<claude-mem-context>')) {
|
if (mdContent.includes('<claude-mem-context>')) {
|
||||||
console.log(` Context: Active (${GEMINI_MD_PATH})`);
|
console.log(`Context: Active (${GEMINI_MD_PATH})`);
|
||||||
} else {
|
} else {
|
||||||
console.log(` Context: GEMINI.md exists but no context tags`);
|
console.log('Context: GEMINI.md exists but missing claude-mem section');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(` Context: No GEMINI.md file`);
|
console.log('Context: No GEMINI.md found');
|
||||||
}
|
|
||||||
|
|
||||||
// Check expected vs actual events
|
|
||||||
const expectedEvents = Object.keys(GEMINI_EVENTS);
|
|
||||||
const missingEvents = expectedEvents.filter((e) => !installedEvents.includes(e));
|
|
||||||
if (missingEvents.length > 0) {
|
|
||||||
console.log(` Warning: Missing events: ${missingEvents.join(', ')}`);
|
|
||||||
console.log(' Run install again to add missing hooks.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
console.log('Status: Unknown');
|
|
||||||
console.log(' Could not parse settings.json.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle gemini-cli subcommand for hooks management.
|
||||||
|
*/
|
||||||
|
export async function handleGeminiCliCommand(subcommand: string, _args: string[]): Promise<number> {
|
||||||
|
switch (subcommand) {
|
||||||
|
case 'install':
|
||||||
|
return installGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'uninstall':
|
||||||
|
return uninstallGeminiCliHooks();
|
||||||
|
|
||||||
|
case 'status':
|
||||||
|
return checkGeminiCliHooksStatus();
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`
|
||||||
|
Claude-Mem Gemini CLI Integration
|
||||||
|
|
||||||
|
Usage: claude-mem gemini-cli <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
install Install hooks into ~/.gemini/settings.json
|
||||||
|
uninstall Remove claude-mem hooks (preserves other hooks)
|
||||||
|
status Check installation status
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
claude-mem gemini-cli install # Install hooks
|
||||||
|
claude-mem gemini-cli status # Check if installed
|
||||||
|
claude-mem gemini-cli uninstall # Remove hooks
|
||||||
|
|
||||||
|
For more info: https://docs.claude-mem.ai/usage/gemini-provider
|
||||||
|
`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,14 +21,13 @@ import { homedir } from 'os';
|
|||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { findMcpServerPath } from './CursorHooksInstaller.js';
|
import { findMcpServerPath } from './CursorHooksInstaller.js';
|
||||||
|
import { readJsonSafe } from '../../utils/json-utils.js';
|
||||||
|
import { injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Shared Constants
|
// Shared Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const CONTEXT_TAG_OPEN = '<claude-mem-context>';
|
|
||||||
const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
|
||||||
|
|
||||||
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
|
const PLACEHOLDER_CONTEXT = `# claude-mem: Cross-Session Memory
|
||||||
|
|
||||||
*No context yet. Complete your first session and context will appear here.*
|
*No context yet. Complete your first session and context will appear here.*
|
||||||
@@ -45,57 +44,11 @@ Use claude-mem's MCP search tools for manual memory queries.`;
|
|||||||
*/
|
*/
|
||||||
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||||
return {
|
return {
|
||||||
command: 'node',
|
command: process.execPath,
|
||||||
args: [mcpServerPath],
|
args: [mcpServerPath],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a JSON file safely, returning a default value if it doesn't exist or is corrupt.
|
|
||||||
*/
|
|
||||||
function readJsonSafe<T>(filePath: string, defaultValue: T): T {
|
|
||||||
if (!existsSync(filePath)) return defaultValue;
|
|
||||||
try {
|
|
||||||
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('MCP', `Corrupt JSON file, using default`, { path: filePath }, error as Error);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject or update a <claude-mem-context> section in a markdown file.
|
|
||||||
* Creates the file if it doesn't exist. Preserves content outside the tags.
|
|
||||||
*/
|
|
||||||
function injectContextIntoMarkdownFile(filePath: string, contextContent: string): void {
|
|
||||||
const parentDirectory = path.dirname(filePath);
|
|
||||||
mkdirSync(parentDirectory, { recursive: true });
|
|
||||||
|
|
||||||
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
|
|
||||||
|
|
||||||
if (existsSync(filePath)) {
|
|
||||||
let existingContent = readFileSync(filePath, 'utf-8');
|
|
||||||
|
|
||||||
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
|
|
||||||
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
|
|
||||||
|
|
||||||
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
|
||||||
// Replace existing section
|
|
||||||
existingContent =
|
|
||||||
existingContent.slice(0, tagStartIndex) +
|
|
||||||
wrappedContent +
|
|
||||||
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
|
|
||||||
} else {
|
|
||||||
// Append section
|
|
||||||
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(filePath, existingContent, 'utf-8');
|
|
||||||
} else {
|
|
||||||
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write a standard MCP JSON config file, merging with existing config.
|
* Write a standard MCP JSON config file, merging with existing config.
|
||||||
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
|
* Supports both { "mcpServers": { ... } } and { "servers": { ... } } formats.
|
||||||
@@ -120,105 +73,30 @@ function writeMcpJsonConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Copilot CLI
|
// MCP Installer Factory (Phase 1D)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Copilot CLI MCP config path.
|
* Configuration for a JSON-based MCP IDE integration.
|
||||||
* Copilot CLI uses ~/.github/copilot/mcp.json for user-level MCP config.
|
|
||||||
*/
|
*/
|
||||||
function getCopilotCliMcpConfigPath(): string {
|
interface McpInstallerConfig {
|
||||||
return path.join(homedir(), '.github', 'copilot', 'mcp.json');
|
ideId: string;
|
||||||
|
ideLabel: string;
|
||||||
|
configPath: string;
|
||||||
|
configKey: 'servers' | 'mcpServers';
|
||||||
|
contextFile?: {
|
||||||
|
path: string;
|
||||||
|
isWorkspaceRelative: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the Copilot CLI context injection path for the current workspace.
|
* Factory function that creates an MCP installer for any JSON-config-based IDE.
|
||||||
* Copilot reads instructions from .github/copilot-instructions.md in the workspace.
|
* Handles MCP config writing and optional context injection.
|
||||||
*/
|
*/
|
||||||
function getCopilotCliContextPath(): string {
|
function installMcpIntegration(config: McpInstallerConfig): () => Promise<number> {
|
||||||
return path.join(process.cwd(), '.github', 'copilot-instructions.md');
|
return async (): Promise<number> => {
|
||||||
}
|
console.log(`\nInstalling Claude-Mem MCP integration for ${config.ideLabel}...\n`);
|
||||||
|
|
||||||
/**
|
|
||||||
* Install claude-mem MCP integration for Copilot CLI.
|
|
||||||
*
|
|
||||||
* - Writes MCP config to ~/.github/copilot/mcp.json
|
|
||||||
* - Injects context into .github/copilot-instructions.md in the workspace
|
|
||||||
*
|
|
||||||
* @returns 0 on success, 1 on failure
|
|
||||||
*/
|
|
||||||
export async function installCopilotCliMcpIntegration(): Promise<number> {
|
|
||||||
console.log('\nInstalling Claude-Mem MCP integration for Copilot CLI...\n');
|
|
||||||
|
|
||||||
const mcpServerPath = findMcpServerPath();
|
|
||||||
if (!mcpServerPath) {
|
|
||||||
console.error('Could not find MCP server script');
|
|
||||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write MCP config — Copilot CLI uses { "servers": { ... } } format
|
|
||||||
const configPath = getCopilotCliMcpConfigPath();
|
|
||||||
writeMcpJsonConfig(configPath, mcpServerPath, 'servers');
|
|
||||||
console.log(` MCP config written to: ${configPath}`);
|
|
||||||
|
|
||||||
// Inject context into workspace instructions
|
|
||||||
const contextPath = getCopilotCliContextPath();
|
|
||||||
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
|
||||||
console.log(` Context placeholder written to: ${contextPath}`);
|
|
||||||
|
|
||||||
console.log(`
|
|
||||||
Installation complete!
|
|
||||||
|
|
||||||
MCP config: ${configPath}
|
|
||||||
Context: ${contextPath}
|
|
||||||
|
|
||||||
Note: This is an MCP-only integration providing search tools and context.
|
|
||||||
Transcript capture is not available for Copilot CLI.
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
|
||||||
2. Restart Copilot CLI to pick up the MCP server
|
|
||||||
`);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Antigravity
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Antigravity MCP config path.
|
|
||||||
* Antigravity stores MCP config at ~/.gemini/antigravity/mcp_config.json.
|
|
||||||
*/
|
|
||||||
function getAntigravityMcpConfigPath(): string {
|
|
||||||
return path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Antigravity context injection path for the current workspace.
|
|
||||||
* Antigravity reads agent rules from .agent/rules/ in the workspace.
|
|
||||||
*/
|
|
||||||
function getAntigravityContextPath(): string {
|
|
||||||
return path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install claude-mem MCP integration for Antigravity.
|
|
||||||
*
|
|
||||||
* - Writes MCP config to ~/.gemini/antigravity/mcp_config.json
|
|
||||||
* - Injects context into .agent/rules/claude-mem-context.md in the workspace
|
|
||||||
*
|
|
||||||
* @returns 0 on success, 1 on failure
|
|
||||||
*/
|
|
||||||
export async function installAntigravityMcpIntegration(): Promise<number> {
|
|
||||||
console.log('\nInstalling Claude-Mem MCP integration for Antigravity...\n');
|
|
||||||
|
|
||||||
const mcpServerPath = findMcpServerPath();
|
const mcpServerPath = findMcpServerPath();
|
||||||
if (!mcpServerPath) {
|
if (!mcpServerPath) {
|
||||||
@@ -229,38 +107,108 @@ export async function installAntigravityMcpIntegration(): Promise<number> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Write MCP config
|
// Write MCP config
|
||||||
const configPath = getAntigravityMcpConfigPath();
|
const configPath = config.configPath;
|
||||||
writeMcpJsonConfig(configPath, mcpServerPath);
|
|
||||||
console.log(` MCP config written to: ${configPath}`);
|
|
||||||
|
|
||||||
// Inject context into workspace rules
|
// Warp special case: skip config write if ~/.warp/ doesn't exist
|
||||||
const contextPath = getAntigravityContextPath();
|
if (config.ideId === 'warp' && !existsSync(path.dirname(configPath))) {
|
||||||
|
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
|
||||||
|
} else {
|
||||||
|
writeMcpJsonConfig(configPath, mcpServerPath, config.configKey);
|
||||||
|
console.log(` MCP config written to: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject context if configured
|
||||||
|
let contextPath: string | undefined;
|
||||||
|
if (config.contextFile) {
|
||||||
|
contextPath = config.contextFile.path;
|
||||||
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
||||||
console.log(` Context placeholder written to: ${contextPath}`);
|
console.log(` Context placeholder written to: ${contextPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`
|
// Print summary
|
||||||
Installation complete!
|
const summaryLines = [`\nInstallation complete!\n`];
|
||||||
|
summaryLines.push(`MCP config: ${configPath}`);
|
||||||
MCP config: ${configPath}
|
if (contextPath) {
|
||||||
Context: ${contextPath}
|
summaryLines.push(`Context: ${contextPath}`);
|
||||||
|
}
|
||||||
Note: This is an MCP-only integration providing search tools and context.
|
summaryLines.push('');
|
||||||
Transcript capture is not available for Antigravity.
|
summaryLines.push(`Note: This is an MCP-only integration providing search tools and context.`);
|
||||||
|
summaryLines.push(`Transcript capture is not available for ${config.ideLabel}.`);
|
||||||
Next steps:
|
if (config.ideId === 'warp') {
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
summaryLines.push('If MCP config via file is not supported, configure MCP through Warp Drive UI.');
|
||||||
2. Restart Antigravity to pick up the MCP server
|
}
|
||||||
`);
|
summaryLines.push('');
|
||||||
|
summaryLines.push('Next steps:');
|
||||||
|
summaryLines.push(' 1. Start claude-mem worker: npx claude-mem start');
|
||||||
|
summaryLines.push(` 2. Restart ${config.ideLabel} to pick up the MCP server`);
|
||||||
|
summaryLines.push('');
|
||||||
|
console.log(summaryLines.join('\n'));
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Goose
|
// Factory Configs for JSON-based IDEs
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const COPILOT_CLI_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'copilot-cli',
|
||||||
|
ideLabel: 'Copilot CLI',
|
||||||
|
configPath: path.join(homedir(), '.github', 'copilot', 'mcp.json'),
|
||||||
|
configKey: 'servers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.github', 'copilot-instructions.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ANTIGRAVITY_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'antigravity',
|
||||||
|
ideLabel: 'Antigravity',
|
||||||
|
configPath: path.join(homedir(), '.gemini', 'antigravity', 'mcp_config.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.agent', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const CRUSH_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'crush',
|
||||||
|
ideLabel: 'Crush',
|
||||||
|
configPath: path.join(homedir(), '.config', 'crush', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROO_CODE_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'roo-code',
|
||||||
|
ideLabel: 'Roo Code',
|
||||||
|
configPath: path.join(process.cwd(), '.roo', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const WARP_CONFIG: McpInstallerConfig = {
|
||||||
|
ideId: 'warp',
|
||||||
|
ideLabel: 'Warp',
|
||||||
|
configPath: path.join(homedir(), '.warp', 'mcp.json'),
|
||||||
|
configKey: 'mcpServers',
|
||||||
|
contextFile: {
|
||||||
|
path: path.join(process.cwd(), 'WARP.md'),
|
||||||
|
isWorkspaceRelative: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Goose (YAML-based — separate handler)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,7 +238,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
|
|||||||
return [
|
return [
|
||||||
'mcpServers:',
|
'mcpServers:',
|
||||||
' claude-mem:',
|
' claude-mem:',
|
||||||
' command: node',
|
` command: ${process.execPath}`,
|
||||||
' args:',
|
' args:',
|
||||||
` - ${mcpServerPath}`,
|
` - ${mcpServerPath}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -302,7 +250,7 @@ function buildGooseMcpYamlBlock(mcpServerPath: string): string {
|
|||||||
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
|
function buildGooseClaudeMemEntryYaml(mcpServerPath: string): string {
|
||||||
return [
|
return [
|
||||||
' claude-mem:',
|
' claude-mem:',
|
||||||
' command: node',
|
` command: ${process.execPath}`,
|
||||||
' args:',
|
' args:',
|
||||||
` - ${mcpServerPath}`,
|
` - ${mcpServerPath}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
@@ -392,206 +340,6 @@ Next steps:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Crush
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Crush MCP config path.
|
|
||||||
* Crush stores MCP config at ~/.config/crush/mcp.json.
|
|
||||||
*/
|
|
||||||
function getCrushMcpConfigPath(): string {
|
|
||||||
return path.join(homedir(), '.config', 'crush', 'mcp.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install claude-mem MCP integration for Crush.
|
|
||||||
*
|
|
||||||
* - Writes MCP config to ~/.config/crush/mcp.json
|
|
||||||
*
|
|
||||||
* @returns 0 on success, 1 on failure
|
|
||||||
*/
|
|
||||||
export async function installCrushMcpIntegration(): Promise<number> {
|
|
||||||
console.log('\nInstalling Claude-Mem MCP integration for Crush...\n');
|
|
||||||
|
|
||||||
const mcpServerPath = findMcpServerPath();
|
|
||||||
if (!mcpServerPath) {
|
|
||||||
console.error('Could not find MCP server script');
|
|
||||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write MCP config
|
|
||||||
const configPath = getCrushMcpConfigPath();
|
|
||||||
writeMcpJsonConfig(configPath, mcpServerPath);
|
|
||||||
console.log(` MCP config written to: ${configPath}`);
|
|
||||||
|
|
||||||
console.log(`
|
|
||||||
Installation complete!
|
|
||||||
|
|
||||||
MCP config: ${configPath}
|
|
||||||
|
|
||||||
Note: This is an MCP-only integration providing search tools and context.
|
|
||||||
Transcript capture is not available for Crush.
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
|
||||||
2. Restart Crush to pick up the MCP server
|
|
||||||
`);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Roo Code
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Roo Code MCP config path for the current workspace.
|
|
||||||
* Roo Code reads MCP config from .roo/mcp.json in the workspace.
|
|
||||||
*/
|
|
||||||
function getRooCodeMcpConfigPath(): string {
|
|
||||||
return path.join(process.cwd(), '.roo', 'mcp.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Roo Code context injection path for the current workspace.
|
|
||||||
* Roo Code reads rules from .roo/rules/ in the workspace.
|
|
||||||
*/
|
|
||||||
function getRooCodeContextPath(): string {
|
|
||||||
return path.join(process.cwd(), '.roo', 'rules', 'claude-mem-context.md');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install claude-mem MCP integration for Roo Code.
|
|
||||||
*
|
|
||||||
* - Writes MCP config to .roo/mcp.json in the workspace
|
|
||||||
* - Injects context into .roo/rules/claude-mem-context.md in the workspace
|
|
||||||
*
|
|
||||||
* @returns 0 on success, 1 on failure
|
|
||||||
*/
|
|
||||||
export async function installRooCodeMcpIntegration(): Promise<number> {
|
|
||||||
console.log('\nInstalling Claude-Mem MCP integration for Roo Code...\n');
|
|
||||||
|
|
||||||
const mcpServerPath = findMcpServerPath();
|
|
||||||
if (!mcpServerPath) {
|
|
||||||
console.error('Could not find MCP server script');
|
|
||||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write MCP config to workspace
|
|
||||||
const configPath = getRooCodeMcpConfigPath();
|
|
||||||
writeMcpJsonConfig(configPath, mcpServerPath);
|
|
||||||
console.log(` MCP config written to: ${configPath}`);
|
|
||||||
|
|
||||||
// Inject context into workspace rules
|
|
||||||
const contextPath = getRooCodeContextPath();
|
|
||||||
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
|
||||||
console.log(` Context placeholder written to: ${contextPath}`);
|
|
||||||
|
|
||||||
console.log(`
|
|
||||||
Installation complete!
|
|
||||||
|
|
||||||
MCP config: ${configPath}
|
|
||||||
Context: ${contextPath}
|
|
||||||
|
|
||||||
Note: This is an MCP-only integration providing search tools and context.
|
|
||||||
Transcript capture is not available for Roo Code.
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
|
||||||
2. Restart Roo Code to pick up the MCP server
|
|
||||||
`);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Warp
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Warp context injection path for the current workspace.
|
|
||||||
* Warp reads project-level instructions from WARP.md in the project root.
|
|
||||||
*/
|
|
||||||
function getWarpContextPath(): string {
|
|
||||||
return path.join(process.cwd(), 'WARP.md');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Warp MCP config path.
|
|
||||||
* Warp stores MCP config at ~/.warp/mcp.json when supported.
|
|
||||||
*/
|
|
||||||
function getWarpMcpConfigPath(): string {
|
|
||||||
return path.join(homedir(), '.warp', 'mcp.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Install claude-mem MCP integration for Warp.
|
|
||||||
*
|
|
||||||
* - Writes MCP config to ~/.warp/mcp.json
|
|
||||||
* - Injects context into WARP.md in the project root
|
|
||||||
*
|
|
||||||
* @returns 0 on success, 1 on failure
|
|
||||||
*/
|
|
||||||
export async function installWarpMcpIntegration(): Promise<number> {
|
|
||||||
console.log('\nInstalling Claude-Mem MCP integration for Warp...\n');
|
|
||||||
|
|
||||||
const mcpServerPath = findMcpServerPath();
|
|
||||||
if (!mcpServerPath) {
|
|
||||||
console.error('Could not find MCP server script');
|
|
||||||
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Write MCP config — Warp may also support configuring MCP via Warp Drive UI
|
|
||||||
const configPath = getWarpMcpConfigPath();
|
|
||||||
if (existsSync(path.dirname(configPath))) {
|
|
||||||
writeMcpJsonConfig(configPath, mcpServerPath);
|
|
||||||
console.log(` MCP config written to: ${configPath}`);
|
|
||||||
} else {
|
|
||||||
console.log(` Note: ~/.warp/ not found. MCP may need to be configured via Warp Drive UI.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject context into project-level WARP.md
|
|
||||||
const contextPath = getWarpContextPath();
|
|
||||||
injectContextIntoMarkdownFile(contextPath, PLACEHOLDER_CONTEXT);
|
|
||||||
console.log(` Context placeholder written to: ${contextPath}`);
|
|
||||||
|
|
||||||
console.log(`
|
|
||||||
Installation complete!
|
|
||||||
|
|
||||||
MCP config: ${configPath}
|
|
||||||
Context: ${contextPath}
|
|
||||||
|
|
||||||
Note: This is an MCP-only integration providing search tools and context.
|
|
||||||
Transcript capture is not available for Warp.
|
|
||||||
If MCP config via file is not supported, configure MCP through Warp Drive UI.
|
|
||||||
|
|
||||||
Next steps:
|
|
||||||
1. Start claude-mem worker: npx claude-mem start
|
|
||||||
2. Restart Warp to pick up the MCP server
|
|
||||||
`);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`\nInstallation failed: ${(error as Error).message}`);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unified Installer (used by npx install command)
|
// Unified Installer (used by npx install command)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -601,10 +349,10 @@ Next steps:
|
|||||||
* Used by the install command to dispatch to the correct integration.
|
* Used by the install command to dispatch to the correct integration.
|
||||||
*/
|
*/
|
||||||
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
|
export const MCP_IDE_INSTALLERS: Record<string, () => Promise<number>> = {
|
||||||
'copilot-cli': installCopilotCliMcpIntegration,
|
'copilot-cli': installMcpIntegration(COPILOT_CLI_CONFIG),
|
||||||
'antigravity': installAntigravityMcpIntegration,
|
'antigravity': installMcpIntegration(ANTIGRAVITY_CONFIG),
|
||||||
'goose': installGooseMcpIntegration,
|
'goose': installGooseMcpIntegration,
|
||||||
'crush': installCrushMcpIntegration,
|
'crush': installMcpIntegration(CRUSH_CONFIG),
|
||||||
'roo-code': installRooCodeMcpIntegration,
|
'roo-code': installMcpIntegration(ROO_CODE_CONFIG),
|
||||||
'warp': installWarpMcpIntegration,
|
'warp': installMcpIntegration(WARP_CONFIG),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import path from 'path';
|
|||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, unlinkSync } from 'fs';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE, injectContextIntoMarkdownFile } from '../../utils/context-injection.js';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Path Resolution
|
// Path Resolution
|
||||||
@@ -125,9 +126,6 @@ export function installOpenCodePlugin(): number {
|
|||||||
// Context Injection (AGENTS.md)
|
// Context Injection (AGENTS.md)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const CONTEXT_TAG_OPEN = '<claude-mem-context>';
|
|
||||||
const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
|
* Inject or update claude-mem context in OpenCode's AGENTS.md file.
|
||||||
*
|
*
|
||||||
@@ -140,37 +138,9 @@ const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
|||||||
*/
|
*/
|
||||||
export function injectContextIntoAgentsMd(contextContent: string): number {
|
export function injectContextIntoAgentsMd(contextContent: string): number {
|
||||||
const agentsMdPath = getOpenCodeAgentsMdPath();
|
const agentsMdPath = getOpenCodeAgentsMdPath();
|
||||||
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configDirectory = getOpenCodeConfigDirectory();
|
injectContextIntoMarkdownFile(agentsMdPath, contextContent, '# Claude-Mem Memory Context');
|
||||||
mkdirSync(configDirectory, { recursive: true });
|
|
||||||
|
|
||||||
if (existsSync(agentsMdPath)) {
|
|
||||||
let existingContent = readFileSync(agentsMdPath, 'utf-8');
|
|
||||||
|
|
||||||
// Check if context tags already exist
|
|
||||||
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
|
|
||||||
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
|
|
||||||
|
|
||||||
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
|
||||||
// Replace existing section
|
|
||||||
existingContent =
|
|
||||||
existingContent.slice(0, tagStartIndex) +
|
|
||||||
wrappedContent +
|
|
||||||
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
|
|
||||||
} else {
|
|
||||||
// Append section
|
|
||||||
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(agentsMdPath, existingContent, 'utf-8');
|
|
||||||
} else {
|
|
||||||
// Create new AGENTS.md with context
|
|
||||||
const newContent = `# Claude-Mem Memory Context\n\n${wrappedContent}\n`;
|
|
||||||
writeFileSync(agentsMdPath, newContent, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
|
logger.info('OPENCODE', 'Context injected into AGENTS.md', { path: agentsMdPath });
|
||||||
return 0;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ import {
|
|||||||
updateCursorContextForProject,
|
updateCursorContextForProject,
|
||||||
handleCursorCommand
|
handleCursorCommand
|
||||||
} from './integrations/CursorHooksInstaller.js';
|
} from './integrations/CursorHooksInstaller.js';
|
||||||
|
import {
|
||||||
|
handleGeminiCliCommand
|
||||||
|
} from './integrations/GeminiCliHooksInstaller.js';
|
||||||
|
|
||||||
// Service layer imports
|
// Service layer imports
|
||||||
import { DatabaseManager } from './worker/DatabaseManager.js';
|
import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||||
@@ -128,6 +131,10 @@ import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
|
|||||||
// Process management for zombie cleanup (Issue #737)
|
// Process management for zombie cleanup (Issue #737)
|
||||||
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
import { startOrphanReaper, reapOrphanedProcesses, getProcessBySession, ensureProcessExit } from './worker/ProcessRegistry.js';
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI session monitoring
|
||||||
|
import { TranscriptWatcher } from './transcripts/watcher.js';
|
||||||
|
import { loadTranscriptWatchConfig, expandHomePath, DEFAULT_CONFIG_PATH as TRANSCRIPT_CONFIG_PATH } from './transcripts/config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build JSON status output for hook framework communication.
|
* Build JSON status output for hook framework communication.
|
||||||
* This is a pure function extracted for testability.
|
* This is a pure function extracted for testability.
|
||||||
@@ -189,6 +196,9 @@ export class WorkerService {
|
|||||||
// Stale session reaper interval (Issue #1168)
|
// Stale session reaper interval (Issue #1168)
|
||||||
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
private staleSessionReaperInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
// Transcript watcher for external CLI sessions (e.g. Codex, Gemini)
|
||||||
|
private transcriptWatcher: TranscriptWatcher | null = null;
|
||||||
|
|
||||||
// AI interaction tracking for health endpoint
|
// AI interaction tracking for health endpoint
|
||||||
private lastAiInteraction: {
|
private lastAiInteraction: {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -421,6 +431,22 @@ export class WorkerService {
|
|||||||
this.resolveInitialization();
|
this.resolveInitialization();
|
||||||
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
logger.info('SYSTEM', 'Core initialization complete (DB + search ready)');
|
||||||
|
|
||||||
|
// Auto-start transcript watchers if configured
|
||||||
|
if (existsSync(TRANSCRIPT_CONFIG_PATH)) {
|
||||||
|
try {
|
||||||
|
const transcriptConfig = loadTranscriptWatchConfig(TRANSCRIPT_CONFIG_PATH);
|
||||||
|
if (transcriptConfig.watches.length > 0) {
|
||||||
|
const transcriptStatePath = expandHomePath(transcriptConfig.stateFile ?? '~/.claude-mem/transcript-watch-state.json');
|
||||||
|
this.transcriptWatcher = new TranscriptWatcher(transcriptConfig, transcriptStatePath);
|
||||||
|
await this.transcriptWatcher.start();
|
||||||
|
logger.info('SYSTEM', `Transcript watcher started with ${transcriptConfig.watches.length} watch target(s)`);
|
||||||
|
}
|
||||||
|
} catch (transcriptError) {
|
||||||
|
logger.warn('SYSTEM', 'Failed to start transcript watcher (non-fatal)', {}, transcriptError as Error);
|
||||||
|
// Non-fatal — worker continues without transcript watching
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
// Auto-backfill Chroma for all projects if out of sync with SQLite (fire-and-forget)
|
||||||
if (this.chromaMcpManager) {
|
if (this.chromaMcpManager) {
|
||||||
ChromaSync.backfillAllProjects().then(() => {
|
ChromaSync.backfillAllProjects().then(() => {
|
||||||
@@ -922,6 +948,13 @@ export class WorkerService {
|
|||||||
this.staleSessionReaperInterval = null;
|
this.staleSessionReaperInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop transcript watcher
|
||||||
|
if (this.transcriptWatcher) {
|
||||||
|
this.transcriptWatcher.stop();
|
||||||
|
this.transcriptWatcher = null;
|
||||||
|
logger.info('SYSTEM', 'Transcript watcher stopped');
|
||||||
|
}
|
||||||
|
|
||||||
await performGracefulShutdown({
|
await performGracefulShutdown({
|
||||||
server: this.server.getHttpServer(),
|
server: this.server.getHttpServer(),
|
||||||
sessionManager: this.sessionManager,
|
sessionManager: this.sessionManager,
|
||||||
@@ -1174,14 +1207,21 @@ async function main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'gemini-cli': {
|
||||||
|
const geminiSubcommand = process.argv[3];
|
||||||
|
const geminiResult = await handleGeminiCliCommand(geminiSubcommand, process.argv.slice(4));
|
||||||
|
process.exit(geminiResult);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'hook': {
|
case 'hook': {
|
||||||
// Validate CLI args first (before any I/O)
|
// Validate CLI args first (before any I/O)
|
||||||
const platform = process.argv[3];
|
const platform = process.argv[3];
|
||||||
const event = process.argv[4];
|
const event = process.argv[4];
|
||||||
if (!platform || !event) {
|
if (!platform || !event) {
|
||||||
console.error('Usage: claude-mem hook <platform> <event>');
|
console.error('Usage: claude-mem hook <platform> <event>');
|
||||||
console.error('Platforms: claude-code, cursor, raw');
|
console.error('Platforms: claude-code, cursor, gemini-cli, raw');
|
||||||
console.error('Events: context, session-init, observation, summarize, session-complete');
|
console.error('Events: context, session-init, observation, summarize, session-complete, user-message');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Shared context injection utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides tag constants and a function to inject or update a
|
||||||
|
* <claude-mem-context> section in any markdown file. Used by
|
||||||
|
* MCP integrations and OpenCode installer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tag Constants
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CONTEXT_TAG_OPEN = '<claude-mem-context>';
|
||||||
|
export const CONTEXT_TAG_CLOSE = '</claude-mem-context>';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context Injection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject or update a <claude-mem-context> section in a markdown file.
|
||||||
|
* Creates the file if it doesn't exist. Preserves content outside the tags.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the target markdown file.
|
||||||
|
* @param contextContent - The content to place between the context tags.
|
||||||
|
* @param headerLine - Optional first line written when creating a new file
|
||||||
|
* (e.g. `"# Claude-Mem Memory Context"` for AGENTS.md).
|
||||||
|
*/
|
||||||
|
export function injectContextIntoMarkdownFile(
|
||||||
|
filePath: string,
|
||||||
|
contextContent: string,
|
||||||
|
headerLine?: string,
|
||||||
|
): void {
|
||||||
|
const parentDirectory = path.dirname(filePath);
|
||||||
|
mkdirSync(parentDirectory, { recursive: true });
|
||||||
|
|
||||||
|
const wrappedContent = `${CONTEXT_TAG_OPEN}\n${contextContent}\n${CONTEXT_TAG_CLOSE}`;
|
||||||
|
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
let existingContent = readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
const tagStartIndex = existingContent.indexOf(CONTEXT_TAG_OPEN);
|
||||||
|
const tagEndIndex = existingContent.indexOf(CONTEXT_TAG_CLOSE);
|
||||||
|
|
||||||
|
if (tagStartIndex !== -1 && tagEndIndex !== -1) {
|
||||||
|
// Replace existing section
|
||||||
|
existingContent =
|
||||||
|
existingContent.slice(0, tagStartIndex) +
|
||||||
|
wrappedContent +
|
||||||
|
existingContent.slice(tagEndIndex + CONTEXT_TAG_CLOSE.length);
|
||||||
|
} else {
|
||||||
|
// Append section
|
||||||
|
existingContent = existingContent.trimEnd() + '\n\n' + wrappedContent + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(filePath, existingContent, 'utf-8');
|
||||||
|
} else {
|
||||||
|
// Create new file
|
||||||
|
if (headerLine) {
|
||||||
|
writeFileSync(filePath, `${headerLine}\n\n${wrappedContent}\n`, 'utf-8');
|
||||||
|
} else {
|
||||||
|
writeFileSync(filePath, wrappedContent + '\n', 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Shared JSON file utilities for claude-mem.
|
||||||
|
*
|
||||||
|
* Provides safe read/write helpers used across the CLI and services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a JSON file safely, returning a default value if the file
|
||||||
|
* does not exist or contains corrupt JSON.
|
||||||
|
*
|
||||||
|
* @param filePath - Absolute path to the JSON file.
|
||||||
|
* @param defaultValue - Value returned when the file is missing or unreadable.
|
||||||
|
* @returns The parsed JSON content, or `defaultValue` on failure.
|
||||||
|
*/
|
||||||
|
export function readJsonSafe<T>(filePath: string, defaultValue: T): T {
|
||||||
|
if (!existsSync(filePath)) return defaultValue;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('JSON', `Corrupt JSON file, using default`, { path: filePath }, error as Error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user