import path from 'path'; import { homedir } from 'os'; import { execFileSync, spawnSync } from 'child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { logger } from '../../utils/logger.js'; import { paths } from '../../shared/paths.js'; const CODEX_DIR = path.join(homedir(), '.codex'); const CODEX_AGENTS_MD_PATH = path.join(CODEX_DIR, 'AGENTS.md'); const CODEX_TRANSCRIPT_WATCH_CONFIG_PATH = paths.transcriptsConfig(); const CODEX_CONFIG_PATH = path.join(CODEX_DIR, 'config.toml'); const MARKETPLACE_NAME = 'claude-mem-local'; const CODEX_PLUGIN_ID = `claude-mem@${MARKETPLACE_NAME}`; const LEGACY_CODEX_PLUGIN_IDS = ['claude-mem@thedotmack']; const MIN_CODEX_MARKETPLACE_VERSION = '0.128.0'; const REQUIRED_MARKETPLACE_FILES = [ path.join('.agents', 'plugins', 'marketplace.json'), path.join('plugin', '.codex-plugin', 'plugin.json'), path.join('plugin', '.mcp.json'), path.join('plugin', 'hooks', 'codex-hooks.json'), path.join('plugin', 'skills', 'mem-search', 'SKILL.md'), ]; function commandExists(command: string): boolean { try { if (process.platform === 'win32') { execFileSync('where', [command], { stdio: 'ignore' }); } else { execFileSync('which', [command], { stdio: 'ignore' }); } return true; } catch { return false; } } function findAncestorWithCodexMarketplace(start: string): string | null { let current = path.resolve(start); while (true) { if (existsSync(path.join(current, '.agents', 'plugins', 'marketplace.json'))) { return current; } const parent = path.dirname(current); if (parent === current) return null; current = parent; } } function missingMarketplaceFiles(root: string): string[] { return REQUIRED_MARKETPLACE_FILES.filter((entry) => !existsSync(path.join(root, entry))); } function assertCodexMarketplaceRoot(root: string): string { const resolved = path.resolve(root); const missing = missingMarketplaceFiles(resolved); if (missing.length > 0) { throw new Error(`Codex marketplace root ${resolved} is missing required files: ${missing.join(', ')}`); } return resolved; } function resolvePluginMarketplaceRoot(preferredRoot?: string): string { if (preferredRoot) { return assertCodexMarketplaceRoot(preferredRoot); } const candidates = [ process.env.CLAUDE_PLUGIN_ROOT, process.env.PLUGIN_ROOT, process.cwd(), path.dirname(fileURLToPath(import.meta.url)), ].filter((value): value is string => Boolean(value)); for (const candidate of candidates) { const resolved = findAncestorWithCodexMarketplace(candidate); if (resolved && missingMarketplaceFiles(resolved).length === 0) return resolved; } throw new Error('Could not locate a Codex marketplace root with .agents/plugins/marketplace.json and plugin/.codex-plugin/plugin.json. Run npx claude-mem@latest install from the package or repo root.'); } function runCodex(args: string[]): void { const result = spawnSync('codex', args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); const output = console; const stdout = result.stdout?.trimEnd(); const stderr = result.stderr?.trimEnd(); if (stdout) output.log(stdout); if (stderr) output.error(stderr); if (result.error) { throw result.error; } if (result.status !== 0) { const exitCode = result.status ?? 'unknown'; throw new Error(`codex ${args.join(' ')} failed with exit code ${exitCode}${stderr ? `: ${stderr}` : ''}`); } } function runCodexBestEffort(args: string[], successMessage: string, failureMessage: string): boolean { try { runCodex(args); console.log(` ${successMessage}`); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(` ${failureMessage}: ${message}`); return false; } } function isMarketplaceDifferentSourceError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return message.includes(`marketplace '${MARKETPLACE_NAME}' is already added from a different source`) || message.includes(`marketplace \`${MARKETPLACE_NAME}\` is already added from a different source`); } function registerCodexMarketplace(marketplaceRoot: string): void { try { runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]); return; } catch (error) { if (!isMarketplaceDifferentSourceError(error)) { throw error; } } console.warn(` Codex marketplace ${MARKETPLACE_NAME} is already registered from another source; replacing it with ${marketplaceRoot}.`); runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]); runCodex(['plugin', 'marketplace', 'add', marketplaceRoot]); } export function setTomlBooleanInTable(content: string, header: string, key: string, enabled: boolean): string { const booleanLine = `${key} = ${enabled ? 'true' : 'false'}`; const lines = content.split('\n'); const headerIndex = lines.findIndex((line) => line.trim() === header); if (headerIndex === -1) { const trimmed = content.trimEnd(); return `${trimmed}${trimmed ? '\n\n' : ''}${header}\n${booleanLine}\n`; } let sectionEnd = headerIndex + 1; while (sectionEnd < lines.length && !/^\s*\[/.test(lines[sectionEnd])) { sectionEnd += 1; } const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const keyPattern = new RegExp(`^\\s*${escapedKey}\\s*=`); const keyIndex = lines.findIndex( (line, index) => index > headerIndex && index < sectionEnd && keyPattern.test(line), ); if (keyIndex === -1) { lines.splice(headerIndex + 1, 0, booleanLine); } else { lines[keyIndex] = booleanLine; } return lines.join('\n'); } export function setTomlPluginEnabled(content: string, pluginId: string, enabled: boolean): string { const escapedPluginId = pluginId.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return setTomlBooleanInTable(content, `[plugins."${escapedPluginId}"]`, 'enabled', enabled); } export function setTomlFeatureEnabled(content: string, featureName: string, enabled: boolean): string { return setTomlBooleanInTable(content, '[features]', featureName, enabled); } function writeCodexPluginConfig(enabled: boolean): boolean { if (!enabled && !existsSync(CODEX_CONFIG_PATH)) return false; mkdirSync(CODEX_DIR, { recursive: true }); const current = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, 'utf-8') : ''; let next = current; if (enabled) { next = setTomlFeatureEnabled(next, 'hooks', true); } for (const legacyPluginId of LEGACY_CODEX_PLUGIN_IDS) { next = setTomlPluginEnabled(next, legacyPluginId, false); } next = setTomlPluginEnabled(next, CODEX_PLUGIN_ID, enabled); if (next === current) return false; writeFileSync(CODEX_CONFIG_PATH, next); return true; } function enableCodexPluginConfig(): void { const changed = writeCodexPluginConfig(true); console.log(` Enabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already enabled)'}`); } function disableCodexPluginConfig(): void { const changed = writeCodexPluginConfig(false); console.log(` Disabled Codex plugin: ${CODEX_PLUGIN_ID}${changed ? '' : ' (already disabled)'}`); } function parseSemver(value: string): [number, number, number] | null { const match = value.match(/(\d+)\.(\d+)\.(\d+)/); if (!match) return null; return [Number(match[1]), Number(match[2]), Number(match[3])]; } function compareSemver(left: [number, number, number], right: [number, number, number]): number { if (left[0] !== right[0]) return left[0] - right[0]; if (left[1] !== right[1]) return left[1] - right[1]; return left[2] - right[2]; } function assertCodexMarketplaceSupported(): void { const result = spawnSync('codex', ['--version'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], }); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim(); if (result.error) { throw result.error; } if (result.status !== 0) { console.warn(` Could not determine Codex CLI version. Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.${output ? `\n${output}` : ''}`); return; } const version = parseSemver(output); if (!version) { console.warn(` Could not parse Codex CLI version from "${output || ''}". Continuing; plugin marketplace support requires ${MIN_CODEX_MARKETPLACE_VERSION} or newer.`); return; } const minimumVersion = parseSemver(MIN_CODEX_MARKETPLACE_VERSION); if (minimumVersion && compareSemver(version, minimumVersion) < 0) { throw new Error(`Codex CLI ${version.join('.')} is too old for plugin marketplace support. Update Codex CLI to ${MIN_CODEX_MARKETPLACE_VERSION} or newer, then run: npx claude-mem@latest install`); } } function removeCodexAgentsMdContext(): boolean { if (!existsSync(CODEX_AGENTS_MD_PATH)) return true; const startTag = ''; const endTag = ''; try { readAndStripContextTags(startTag, endTag); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn('WORKER', 'Failed to clean AGENTS.md context', { error: message }); return false; } } function readAndStripContextTags(startTag: string, endTag: string): void { const content = readFileSync(CODEX_AGENTS_MD_PATH, 'utf-8'); const startIdx = content.indexOf(startTag); const endIdx = content.indexOf(endTag); if (startIdx === -1 || endIdx === -1) return; 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(CODEX_AGENTS_MD_PATH, finalContent + '\n'); } else { writeFileSync(CODEX_AGENTS_MD_PATH, ''); } console.log(` Removed legacy global context from ${CODEX_AGENTS_MD_PATH}`); } const cleanupLegacyCodexAgentsMdContext = removeCodexAgentsMdContext; function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isCodexTranscriptWatch(watch: Record): boolean { return watch.name === 'codex' || watch.schema === 'codex'; } function expandHome(inputPath: string): string { if (inputPath === '~') return homedir(); if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) { return path.join(homedir(), inputPath.slice(2)); } return inputPath; } function isLegacyCodexAgentsContext(context: Record): boolean { if (context.mode !== 'agents') return false; const updateOn = context.updateOn; const hasLegacyUpdateOn = Array.isArray(updateOn) && updateOn.length === 2 && updateOn.includes('session_start') && updateOn.includes('session_end'); if (!hasLegacyUpdateOn) return false; if (context.path === undefined) return true; return typeof context.path === 'string' && path.resolve(expandHome(context.path)) === CODEX_AGENTS_MD_PATH; } function disableCodexTranscriptAgentsContext(): boolean { if (!existsSync(CODEX_TRANSCRIPT_WATCH_CONFIG_PATH)) return true; try { const parsed = JSON.parse(readFileSync(CODEX_TRANSCRIPT_WATCH_CONFIG_PATH, 'utf-8')) as unknown; if (!isRecord(parsed) || !Array.isArray(parsed.watches)) return true; let changed = false; for (const watch of parsed.watches) { if (!isRecord(watch) || !isCodexTranscriptWatch(watch)) continue; if (!isRecord(watch.context) || !isLegacyCodexAgentsContext(watch.context)) continue; delete watch.context; changed = true; } if (changed) { writeFileSync(CODEX_TRANSCRIPT_WATCH_CONFIG_PATH, `${JSON.stringify(parsed, null, 2)}\n`); console.log(` Disabled legacy Codex transcript AGENTS.md context in ${CODEX_TRANSCRIPT_WATCH_CONFIG_PATH}`); } return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.warn('WORKER', 'Failed to disable Codex transcript AGENTS.md context', { error: message }); return false; } } const cleanupLegacyCodexTranscriptAgentsContext = disableCodexTranscriptAgentsContext; export async function installCodexCli(marketplaceRootOverride?: string): Promise { console.log('\nInstalling Claude-Mem for Codex CLI (native hooks)...\n'); if (!commandExists('codex')) { console.error('Codex CLI was not found on PATH.'); console.error('Install Codex, then run: npx claude-mem@latest install'); return 1; } try { assertCodexMarketplaceSupported(); const marketplaceRoot = resolvePluginMarketplaceRoot(marketplaceRootOverride); console.log(` Registering Codex plugin marketplace: ${marketplaceRoot}`); registerCodexMarketplace(marketplaceRoot); enableCodexPluginConfig(); runCodexBestEffort( ['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME], 'Refreshed Codex marketplace and installed plugin cache.', 'Could not refresh Codex marketplace cache; reinstall or upgrade claude-mem from /plugins if Codex still uses old MCP config', ); if (!cleanupLegacyCodexAgentsMdContext()) { console.warn(` Native Codex hooks registered, but failed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`); } if (!cleanupLegacyCodexTranscriptAgentsContext()) { console.warn(` Native Codex hooks registered, but failed to disable legacy transcript AGENTS.md context in ${CODEX_TRANSCRIPT_WATCH_CONFIG_PATH}.`); } console.log(` Installation complete! Codex marketplace: ${MARKETPLACE_NAME} Plugin source: ${marketplaceRoot} Next steps: 1. Open Codex CLI in your project 2. Restart any running Codex sessions so native hooks are loaded For a fresh setup, the supported entry point is: npx claude-mem@latest install `); return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`\nInstallation failed: ${message}`); return 1; } } export function uninstallCodexCli(): number { console.log('\nUninstalling Claude-Mem Codex CLI integration...\n'); let failed = false; try { disableCodexPluginConfig(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`\nCodex plugin config update failed: ${message}`); failed = true; } try { if (commandExists('codex')) { runCodex(['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]); } else { console.log(' Codex CLI not found; skipping marketplace removal.'); } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`\nCodex marketplace removal failed: ${message}`); failed = true; } try { if (!cleanupLegacyCodexAgentsMdContext()) { console.error(`\nFailed to remove legacy AGENTS.md context from ${CODEX_AGENTS_MD_PATH}.`); failed = true; } if (!cleanupLegacyCodexTranscriptAgentsContext()) { console.error(`\nFailed to disable legacy transcript AGENTS.md context in ${CODEX_TRANSCRIPT_WATCH_CONFIG_PATH}.`); failed = true; } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`\nLegacy context cleanup failed: ${message}`); failed = true; } if (failed) { console.error('\nUninstallation completed with errors.'); return 1; } console.log('\nUninstallation complete!'); console.log('Restart Codex CLI to apply changes.\n'); return 0; }