442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
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 || '<empty>'}". 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 = '<claude-mem-context>';
|
|
const endTag = '</claude-mem-context>';
|
|
|
|
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<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function isCodexTranscriptWatch(watch: Record<string, unknown>): 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<string, unknown>): 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<number> {
|
|
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;
|
|
}
|