diff --git a/.env.example b/.env.example index ec0ddba5..8169a46b 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ ASTRAFLOW_CN_API_KEY= # ASTRAFLOW_CN_MODEL=gpt-4o-mini # ASTRAFLOW_CN_BASE_URL=https://api.modelverse.cn/v1 +# ─── ECC agent data (multi-harness isolation) ─────────────────────────────── +# Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude +# Use a separate root when running ECC in Cursor alongside Claude Code: +# ECC_AGENT_DATA_HOME=$HOME/.cursor/ecc + # ─── Session & Security ───────────────────────────────────────────────────── # GitHub username (used by CI scripts for credential context) GITHUB_USER="your-github-username" diff --git a/README.md b/README.md index 9cc9e7d3..2883c76e 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,7 @@ export ECC_SESSION_RETENTION_DAYS=14 export ECC_CONTEXT_MONITOR_COST_WARNINGS=off ``` + Windows PowerShell: ```powershell @@ -494,6 +495,24 @@ Windows PowerShell: [Environment]::SetEnvironmentVariable('ECC_SESSION_RETENTION_DAYS', '14', 'User') ``` +### Agent data home (multi-harness isolation) + +Memory persistence hooks (session summaries, learned skills, session aliases, metrics) store data under a single agent data root. By default that root is `~/.claude`. When you use ECC in both Claude Code and Cursor on the same machine, set a separate root for Cursor so the two environments do not overwrite each other's session files: + +```bash +# Cursor-only boundary (Claude Code keeps the default ~/.claude) +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +Paths resolved under that root include: + +- `$ECC_AGENT_DATA_HOME/session-data/` — session summaries +- `$ECC_AGENT_DATA_HOME/skills/learned/` — learned skills from evaluate-session +- `$ECC_AGENT_DATA_HOME/session-aliases.json` — session aliases +- `$ECC_AGENT_DATA_HOME/metrics/` — cost and activity metrics + +See [affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065). + --- ## What's Inside @@ -1261,6 +1280,25 @@ ECC does not install root `AGENTS.md` into `.cursor/`. Cursor treats nested `AGE Cursor-native loading behavior can vary by Cursor build. ECC installs agents as `.cursor/agents/ecc-*.md`; if your Cursor build does not expose project agents, those files still work as explicit reference definitions instead of hidden global prompt context. +### Memory and data isolation (Cursor + Claude Code) + +ECC memory hooks reuse the same `scripts/hooks/*.js` as Claude Code. For Cursor, ECC tries to keep memory **out of `~/.claude` automatically**: + +1. **Cursor `sessionStart` hook** (installed to `.cursor/hooks.json` on `--target cursor`) injects `ECC_AGENT_DATA_HOME` for the whole composer session. +2. **Hook runtime default** — when `CURSOR_VERSION` or `CURSOR_PROJECT_DIR` is present, hooks default to `~/.cursor/ecc` if the env var is unset. +3. **Project config** — `.cursor/ecc-agent-data.json` documents and overrides the path (`agentDataHome`). +4. **Always-on rule** — `.cursor/rules/ecc-agent-data-home.mdc` reminds the agent where memory lives. + +You can still override explicitly: + +```bash +export ECC_AGENT_DATA_HOME="$HOME/.cursor/ecc" +``` + +To **share** memory with Claude Code on purpose, set `ECC_AGENT_DATA_HOME=~/.claude` in the shell or in `.cursor/ecc-agent-data.json`. + +Continuous learning v2 instincts remain separate under `CLV2_HOMUNCULUS_DIR` (default `~/.local/share/ecc-homunculus`). + ### Hook Architecture (DRY Adapter Pattern) Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. diff --git a/scaffolds/cursor/ecc-agent-data.json b/scaffolds/cursor/ecc-agent-data.json new file mode 100644 index 00000000..a39a46ed --- /dev/null +++ b/scaffolds/cursor/ecc-agent-data.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/json", + "description": "ECC agent data root for this project when using Cursor. Memory hooks read session summaries and learned skills from here instead of ~/.claude.", + "agentDataHome": "~/.cursor/ecc" +} diff --git a/scaffolds/cursor/hooks.json b/scaffolds/cursor/hooks.json new file mode 100644 index 00000000..f4e389e8 --- /dev/null +++ b/scaffolds/cursor/hooks.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node .cursor/scripts/hooks/cursor-session-env.js" + } + ] + } +} diff --git a/scaffolds/cursor/rules/ecc-agent-data-home.mdc b/scaffolds/cursor/rules/ecc-agent-data-home.mdc new file mode 100644 index 00000000..c7e2b608 --- /dev/null +++ b/scaffolds/cursor/rules/ecc-agent-data-home.mdc @@ -0,0 +1,16 @@ +--- +description: "ECC Cursor memory boundary — keeps session data out of Claude Code's ~/.claude" +alwaysApply: true +--- + +# ECC agent data home (Cursor) + +This project uses ECC with **isolated memory persistence** for Cursor: + +- Default data root: `~/.cursor/ecc` (not `~/.claude`) +- Env var: `ECC_AGENT_DATA_HOME` (set automatically by ECC's Cursor `sessionStart` hook when hooks are wired) +- Project override: `.cursor/ecc-agent-data.json` → `agentDataHome` + +If the user asks where ECC stored session context or learned skills, point them at `$ECC_AGENT_DATA_HOME/session-data/` and `$ECC_AGENT_DATA_HOME/skills/learned/`. + +To **share** memory with Claude Code intentionally (not recommended for parallel use), set `ECC_AGENT_DATA_HOME` to `~/.claude` in the shell or in `.cursor/ecc-agent-data.json`. diff --git a/scripts/hooks/cursor-session-env.js b/scripts/hooks/cursor-session-env.js new file mode 100644 index 00000000..e5fe360f --- /dev/null +++ b/scripts/hooks/cursor-session-env.js @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Cursor sessionStart hook — inject ECC_AGENT_DATA_HOME for the composer session. + * + * Cursor passes session-scoped env from sessionStart output to all later hooks. + * @see https://cursor.com/docs/hooks + */ + +const { + getCursorSessionEnvPayload, + resolveAgentDataHome, + AGENT_DATA_HOME_ENV, +} = require('../lib/agent-data-home'); +const { readStdinJson, log } = require('../lib/utils'); + +function main() { + readStdinJson() + .then(() => { + const envPayload = getCursorSessionEnvPayload({ preferCursorDefault: true }); + const agentDataHome = envPayload[AGENT_DATA_HOME_ENV]; + const payload = { + env: envPayload, + additional_context: [ + 'ECC memory persistence uses a dedicated agent data root for this Cursor session.', + `${AGENT_DATA_HOME_ENV}=${agentDataHome}`, + 'Session summaries, learned skills, aliases, and metrics live under that directory.', + 'Override via shell env, project .cursor/ecc-agent-data.json, or ECC docs (issue #2065).', + ].join('\n'), + }; + + process.stdout.write(`${JSON.stringify(payload)}\n`); + log(`[cursor-session-env] Set ${AGENT_DATA_HOME_ENV}=${agentDataHome}`); + process.exit(0); + }) + .catch(error => { + const fallbackHome = resolveAgentDataHome({ preferCursorDefault: true }); + const payload = { + env: { [AGENT_DATA_HOME_ENV]: fallbackHome }, + }; + process.stdout.write(`${JSON.stringify(payload)}\n`); + log(`[cursor-session-env] Fallback ${AGENT_DATA_HOME_ENV}=${fallbackHome} (${error.message})`); + process.exit(0); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { main }; diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index aef94aab..c965d403 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home'); function readStdinRaw() { try { @@ -83,14 +84,16 @@ function findShellBinary() { } function spawnNode(rootDir, relPath, raw, args) { + ensureAgentDataHomeEnv(); + const hookEnv = { + ...process.env, + CLAUDE_PLUGIN_ROOT: rootDir, + ECC_PLUGIN_ROOT: rootDir, + }; return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], { input: raw, encoding: 'utf8', - env: { - ...process.env, - CLAUDE_PLUGIN_ROOT: rootDir, - ECC_PLUGIN_ROOT: rootDir, - }, + env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, @@ -107,14 +110,16 @@ function spawnShell(rootDir, relPath, raw, args) { }; } + ensureAgentDataHomeEnv(); + const hookEnv = { + ...process.env, + CLAUDE_PLUGIN_ROOT: rootDir, + ECC_PLUGIN_ROOT: rootDir, + }; return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], { input: raw, encoding: 'utf8', - env: { - ...process.env, - CLAUDE_PLUGIN_ROOT: rootDir, - ECC_PLUGIN_ROOT: rootDir, - }, + env: hookEnv, cwd: process.cwd(), timeout: 30000, windowsHide: true, diff --git a/scripts/lib/agent-data-home.js b/scripts/lib/agent-data-home.js new file mode 100644 index 00000000..32da5563 --- /dev/null +++ b/scripts/lib/agent-data-home.js @@ -0,0 +1,199 @@ +/** + * Resolve ECC agent data home (memory persistence root) across harnesses. + * + * Docstring policy: public entry points here are documented; small internal + * helpers (e.g. `expandHomePath`, `readProjectConfigAt`) are left undocumented on + * purpose, consistent with ECC script modules elsewhere. Automated PR reviewers + * (e.g. CodeRabbit) may still flag low JSDoc coverage against a high threshold on + * the diff—that check is informational for this repo, not a bar every helper in + * touched files must meet. Prefer clarity in code and tests over blanket JSDoc on + * private helpers unless maintainers adopt a project-wide coverage rule. + * + * @see https://github.com/affaan-m/ECC/issues/2065 + */ + +const fs = require('fs'); +const path = require('path'); + +const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME'; +const DEFAULT_CLAUDE_DIR_NAME = '.claude'; +const DEFAULT_CURSOR_ECC_DIR_SEGMENTS = ['.cursor', 'ecc']; +const PROJECT_CONFIG_RELATIVE = path.join('.cursor', 'ecc-agent-data.json'); + +/** + * Home directory for tilde expansion and default agent-data paths. + * + * Intentionally mirrors `getHomeDir()` in `scripts/lib/utils.js` (HOME/USERPROFILE, + * then `os.homedir()`). Do not import `utils.getHomeDir` here: `utils.js` already + * requires this module (`resolveAgentDataHome`), which would create a circular + * dependency and risk divergent defaults for `~/.cursor/ecc` vs `~/.claude`. + * + * If consolidation is needed later, prefer one of: + * + * | Approach | Tradeoff | + * | --- | --- | + * | Shared `scripts/lib/home-dir.js` imported by both | Clean; breaks the cycle | + * | Keep duplicate + cross-reference comment (this file) | Zero require risk | + * | Move all resolution here; thin-wrap from `utils` | Larger refactor | + */ +function getHomeDirFromEnv() { + const explicitHome = process.env.HOME || process.env.USERPROFILE; + if (explicitHome && String(explicitHome).trim().length > 0) { + return path.resolve(explicitHome); + } + return require('os').homedir(); +} + +function expandHomePath(value, baseDir) { + if (!value || typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('~')) { + const remainder = trimmed.slice(1).replace(/^[/\\]+/, ''); + return remainder ? path.join(getHomeDirFromEnv(), remainder) : getHomeDirFromEnv(); + } + if (path.isAbsolute(trimmed)) { + return path.resolve(trimmed); + } + const base = baseDir && String(baseDir).trim() + ? path.resolve(baseDir) + : process.cwd(); + return path.resolve(base, trimmed); +} + +/** + * Project root for a config file under .cursor/ecc-agent-data.json. + */ +function resolveProjectRootFromConfigPath(configPath) { + const configDir = path.dirname(path.resolve(configPath)); + if (path.basename(configDir) === '.cursor') { + return path.dirname(configDir); + } + return configDir; +} + +/** + * True when the current process is a Cursor hook subprocess. + * Cursor documents CURSOR_VERSION and CURSOR_PROJECT_DIR for hook scripts. + */ +function isCursorHookRuntime() { + if (process.env.CURSOR_VERSION && String(process.env.CURSOR_VERSION).trim()) { + return true; + } + if (process.env.CURSOR_PROJECT_DIR && String(process.env.CURSOR_PROJECT_DIR).trim()) { + return true; + } + return false; +} + +function getDefaultCursorAgentDataHome() { + return path.join(getHomeDirFromEnv(), ...DEFAULT_CURSOR_ECC_DIR_SEGMENTS); +} + +function getDefaultClaudeAgentDataHome() { + return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME); +} + +function readProjectConfigAt(configPath) { + if (!configPath || typeof configPath !== 'string') return null; + if (!fs.existsSync(configPath)) return null; + + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME; + if (typeof candidate !== 'string' || !candidate.trim()) return null; + const projectRoot = resolveProjectRootFromConfigPath(configPath); + return expandHomePath(candidate, projectRoot); + } catch (error) { + console.error( + `[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}` + ); + return null; + } +} + +function readProjectConfig(projectDir) { + if (!projectDir || typeof projectDir !== 'string') return null; + return readProjectConfigAt(path.join(path.resolve(projectDir), PROJECT_CONFIG_RELATIVE)); +} + +function resolveProjectDir() { + const candidates = [ + process.env.CURSOR_PROJECT_DIR, + process.env.CLAUDE_PROJECT_DIR, + process.cwd(), + ]; + + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'string') continue; + const resolved = path.resolve(candidate); + if (fs.existsSync(path.join(resolved, '.cursor'))) { + return resolved; + } + } + + return process.cwd(); +} + +/** + * Resolve agent data home without mutating process.env. + */ +function resolveAgentDataHome(options = {}) { + const fromEnv = expandHomePath(process.env[AGENT_DATA_HOME_ENV]); + if (fromEnv) return fromEnv; + + const projectDir = options.projectDir || resolveProjectDir(); + const fromProject = readProjectConfig(projectDir); + if (fromProject) return fromProject; + + if (options.preferCursorDefault === true || isCursorHookRuntime()) { + return getDefaultCursorAgentDataHome(); + } + + return getDefaultClaudeAgentDataHome(); +} + +/** + * Set ECC_AGENT_DATA_HOME on the current process when unset (hook subprocess safety net). + * @returns {string} Resolved agent data home + */ +function ensureAgentDataHomeEnv(options = {}) { + const resolved = resolveAgentDataHome(options); + if (!expandHomePath(process.env[AGENT_DATA_HOME_ENV])) { + process.env[AGENT_DATA_HOME_ENV] = resolved; + } + return resolved; +} + +/** + * Build Cursor sessionStart hook output env payload. + */ +function getCursorSessionEnvPayload(options = {}) { + const agentDataHome = resolveAgentDataHome({ + ...options, + preferCursorDefault: true, + }); + + return { + ECC_AGENT_DATA_HOME: agentDataHome, + }; +} + +module.exports = { + AGENT_DATA_HOME_ENV, + DEFAULT_CLAUDE_DIR_NAME, + DEFAULT_CURSOR_ECC_DIR_SEGMENTS, + PROJECT_CONFIG_RELATIVE, + expandHomePath, + resolveProjectRootFromConfigPath, + isCursorHookRuntime, + getDefaultCursorAgentDataHome, + getDefaultClaudeAgentDataHome, + readProjectConfig, + readProjectConfigAt, + resolveProjectDir, + resolveAgentDataHome, + ensureAgentDataHomeEnv, + getCursorSessionEnvPayload, +}; diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index dc29f1c8..ce2274ea 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -207,6 +207,52 @@ function readJsonObject(filePath, label) { return parsed; } +function addCursorAgentDataScaffoldOperations(operations, options) { + const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor'); + if (!fs.existsSync(scaffoldRoot)) { + return; + } + + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'), + destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'), + strategy: 'preserve-relative-path', + }); + + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'), + destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'), + strategy: 'preserve-relative-path', + }); + + addJsonMergeOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'), + destinationPath: path.join(options.targetRoot, 'hooks.json'), + }); + + const cursorSessionHookDeps = [ + path.join('scripts', 'hooks', 'cursor-session-env.js'), + path.join('scripts', 'lib', 'agent-data-home.js'), + path.join('scripts', 'lib', 'utils.js'), + ]; + + for (const sourceRelativePath of cursorSessionHookDeps) { + addFileCopyOperation(operations, { + moduleId: options.moduleId, + sourceRoot: options.sourceRoot, + sourceRelativePath, + destinationPath: path.join(options.targetRoot, sourceRelativePath), + strategy: 'preserve-relative-path', + }); + } +} + function addJsonMergeOperation(operations, options) { const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath); if (!fs.existsSync(sourcePath)) { @@ -405,6 +451,12 @@ function planCursorLegacyInstall(context) { destinationPath: path.join(targetRoot, 'mcp.json'), }); + addCursorAgentDataScaffoldOperations(operations, { + moduleId: 'legacy-cursor-install', + sourceRoot: context.sourceRoot, + targetRoot, + }); + return { mode: 'legacy', adapter, diff --git a/scripts/lib/session-aliases.d.ts b/scripts/lib/session-aliases.d.ts index c1744713..3930dc72 100644 --- a/scripts/lib/session-aliases.d.ts +++ b/scripts/lib/session-aliases.d.ts @@ -1,6 +1,6 @@ /** * Session Aliases Library for Claude Code. - * Manages named aliases for session files, stored in ~/.claude/session-aliases.json. + * Manages named aliases for session files, stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude). */ /** Internal alias storage entry */ diff --git a/scripts/lib/session-aliases.js b/scripts/lib/session-aliases.js index cf9acdaa..bfc84631 100644 --- a/scripts/lib/session-aliases.js +++ b/scripts/lib/session-aliases.js @@ -1,6 +1,6 @@ /** * Session Aliases Library for Claude Code - * Manages session aliases stored in ~/.claude/session-aliases.json + * Manages session aliases stored in $ECC_AGENT_DATA_HOME/session-aliases.json (default ~/.claude). */ const fs = require('fs'); diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 55d27621..9e980c69 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -15,19 +15,25 @@ export const isLinux: boolean; /** Get the user's home directory (cross-platform) */ export function getHomeDir(): string; -/** Get the Claude config directory (~/.claude) */ +/** + * ECC agent data root for memory persistence and related state. + * Defaults to ~/.claude; override with ECC_AGENT_DATA_HOME (e.g. ~/.cursor/ecc). + */ +export function getAgentDataHome(): string; + +/** Get the agent data directory (alias of getAgentDataHome) */ export function getClaudeDir(): string; -/** Get the canonical ECC sessions directory (~/.claude/session-data) */ +/** Get the canonical ECC sessions directory ($ECC_AGENT_DATA_HOME/session-data) */ export function getSessionsDir(): string; -/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */ +/** Get the legacy sessions directory ($ECC_AGENT_DATA_HOME/sessions) */ export function getLegacySessionsDir(): string; /** Get session directories to search, with canonical storage first and legacy fallback second */ export function getSessionSearchDirs(): string[]; -/** Get the learned skills directory (~/.claude/skills/learned) */ +/** Get the learned skills directory ($ECC_AGENT_DATA_HOME/skills/learned) */ export function getLearnedSkillsDir(): string; /** Get the temp directory (cross-platform) */ diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index f1717e5d..a201e234 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -15,6 +15,9 @@ const isMacOS = process.platform === 'darwin'; const isLinux = process.platform === 'linux'; const SESSION_DATA_DIR_NAME = 'session-data'; const LEGACY_SESSIONS_DIR_NAME = 'sessions'; +const { + resolveAgentDataHome, +} = require('./agent-data-home'); const WINDOWS_RESERVED_SESSION_IDS = new Set([ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', @@ -33,12 +36,20 @@ function getHomeDir() { } /** - * Get the Claude config directory + * ECC agent data root for memory persistence (see scripts/lib/agent-data-home.js). + */ +function getAgentDataHome() { + return resolveAgentDataHome(); +} + +/** + * Get the Claude config directory (alias of getAgentDataHome for backwards compatibility). */ function getClaudeDir() { - return path.join(getHomeDir(), '.claude'); + return getAgentDataHome(); } + /** * Get the sessions directory */ @@ -585,6 +596,7 @@ module.exports = { // Directories getHomeDir, + getAgentDataHome, getClaudeDir, getSessionsDir, getLegacySessionsDir, diff --git a/tests/lib/agent-data-home.test.js b/tests/lib/agent-data-home.test.js new file mode 100644 index 00000000..21909b56 --- /dev/null +++ b/tests/lib/agent-data-home.test.js @@ -0,0 +1,260 @@ +/** + * Tests for scripts/lib/agent-data-home.js + * + * Run with: node tests/lib/agent-data-home.test.js + * + * Cwd / project context: many cases use `withIsolatedCwd()` (empty temp dir, no + * `.cursor/`) so results do not depend on running inside a dogfooded ECC repo. + * When this repo has `.cursor/ecc-agent-data.json` installed, `resolveAgentDataHome()` + * from the real project root intentionally resolves to `~/.cursor/ecc` — see the + * dedicated test below; do not expect `~/.claude` while cwd is the ECC tree. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function withEnv(overrides, fn) { + const previous = {}; + for (const key of Object.keys(overrides)) { + previous[key] = process.env[key]; + if (overrides[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = overrides[key]; + } + } + + try { + fn(); + } finally { + for (const key of Object.keys(previous)) { + if (previous[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous[key]; + } + } + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + } +} + +/** + * Run fn with cwd in an empty directory (no .cursor/) so resolveProjectDir() does + * not pick up the ECC repo's installed agent-data config. + */ +function withIsolatedCwd(fn) { + const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-')); + const originalCwd = process.cwd(); + try { + process.chdir(isolatedDir); + return fn(isolatedDir); + } finally { + process.chdir(originalCwd); + fs.rmSync(isolatedDir, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing agent-data-home.js ===\n'); + let passed = 0; + let failed = 0; + + if (test('defaults to ~/.claude outside Cursor (isolated cwd)', () => { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const home = os.homedir(); + assert.strictEqual( + agentDataHome.resolveAgentDataHome(), + path.join(home, '.claude') + ); + }); + }); + })) passed++; else failed++; + + if (test('resolveAgentDataHome uses projectDir + .cursor/ecc-agent-data.json', () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-project-')); + const cursorDir = path.join(projectDir, '.cursor'); + fs.mkdirSync(cursorDir, { recursive: true }); + fs.writeFileSync( + path.join(cursorDir, 'ecc-agent-data.json'), + JSON.stringify({ agentDataHome: '~/.cursor/ecc' }), + 'utf8' + ); + + try { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.resolveAgentDataHome({ projectDir }), + path.join(os.homedir(), '.cursor', 'ecc') + ); + }); + }); + } finally { + fs.rmSync(projectDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('defaults to ~/.cursor/ecc in Cursor hook runtime (isolated cwd)', () => { + withIsolatedCwd(() => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: '1.0.0', + CURSOR_PROJECT_DIR: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const home = os.homedir(); + assert.strictEqual( + agentDataHome.resolveAgentDataHome(), + path.join(home, '.cursor', 'ecc') + ); + }); + }); + })) passed++; else failed++; + + if (test('honors ECC_AGENT_DATA_HOME over Cursor default', () => { + const override = path.join(os.tmpdir(), `ecc-override-${Date.now()}`); + withEnv({ + ECC_AGENT_DATA_HOME: override, + CURSOR_VERSION: '1.0.0', + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.resolveAgentDataHome(), path.resolve(override)); + }); + })) passed++; else failed++; + + if (test('reads project ecc-agent-data.json config file', () => { + const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-read-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const configPath = path.join(tmpDir, 'ecc-agent-data.json'); + const customHome = path.join(tmpDir, 'data-root'); + fs.writeFileSync( + configPath, + JSON.stringify({ agentDataHome: customHome }), + 'utf8' + ); + + try { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual( + agentDataHome.readProjectConfigAt(configPath), + path.resolve(customHome) + ); + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('resolves relative agentDataHome against project root, not cwd', () => { + const stamp = Date.now(); + const projectDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-${stamp}`); + const cursorDir = path.join(projectDir, '.cursor'); + const otherCwd = path.join(os.tmpdir(), `ecc-agent-data-home-other-cwd-${stamp}`); + fs.mkdirSync(cursorDir, { recursive: true }); + fs.mkdirSync(otherCwd, { recursive: true }); + const configPath = path.join(cursorDir, 'ecc-agent-data.json'); + const expectedHome = path.join(projectDir, '.ecc-data'); + fs.writeFileSync( + configPath, + JSON.stringify({ agentDataHome: '.ecc-data' }), + 'utf8' + ); + + const originalCwd = process.cwd(); + try { + process.chdir(otherCwd); + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + CURSOR_PROJECT_DIR: projectDir, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), expectedHome); + assert.strictEqual( + agentDataHome.resolveAgentDataHome({ projectDir }), + expectedHome + ); + }); + } finally { + process.chdir(originalCwd); + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(otherCwd, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('readProjectConfigAt logs parse failures', () => { + const tmpDir = path.join(os.tmpdir(), `ecc-agent-data-home-log-${Date.now()}`); + fs.mkdirSync(tmpDir, { recursive: true }); + const configPath = path.join(tmpDir, 'ecc-agent-data.json'); + fs.writeFileSync(configPath, '{ invalid json', 'utf8'); + + const originalError = console.error; + const messages = []; + console.error = (...args) => { + messages.push(args.join(' ')); + }; + + try { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: undefined, + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + assert.strictEqual(agentDataHome.readProjectConfigAt(configPath), null); + assert.ok( + messages.some(message => message.includes(configPath)), + 'Expected config path in error log' + ); + }); + } finally { + console.error = originalError; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('ensureAgentDataHomeEnv sets process.env when unset', () => { + withEnv({ + ECC_AGENT_DATA_HOME: undefined, + CURSOR_VERSION: '1.0.0', + }, () => { + const agentDataHome = require('../../scripts/lib/agent-data-home'); + const resolved = agentDataHome.ensureAgentDataHomeEnv(); + assert.ok(process.env.ECC_AGENT_DATA_HOME); + assert.strictEqual(process.env.ECC_AGENT_DATA_HOME, resolved); + }); + })) passed++; else failed++; + + console.log(`\n=== Test Results ===\nPassed: ${passed}\nFailed: ${failed}\n`); + if (failed > 0) process.exit(1); +} + +runTests(); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index 3217567a..54fa8dfc 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -115,7 +115,79 @@ function runTests() { const sessionsDir = utils.getSessionsDir(); const claudeDir = utils.getClaudeDir(); assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir'); - assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory'); + assert.ok(sessionsDir.endsWith('session-data'), 'Should use canonical session-data directory'); + })) passed++; else failed++; + + if (test('getAgentDataHome honors ECC_AGENT_DATA_HOME', () => { + const original = process.env.ECC_AGENT_DATA_HOME; + const overrideRoot = path.join(utils.getTempDir(), `ecc-agent-data-${Date.now()}`); + try { + process.env.ECC_AGENT_DATA_HOME = overrideRoot; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const reloaded = require('../../scripts/lib/utils'); + assert.strictEqual(reloaded.getAgentDataHome(), path.resolve(overrideRoot)); + assert.strictEqual(reloaded.getClaudeDir(), path.resolve(overrideRoot)); + assert.strictEqual( + reloaded.getSessionsDir(), + path.join(path.resolve(overrideRoot), 'session-data') + ); + assert.strictEqual( + reloaded.getLearnedSkillsDir(), + path.join(path.resolve(overrideRoot), 'skills', 'learned') + ); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + if (original === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = original; + } + } + })) passed++; else failed++; + + if (test('getAgentDataHome defaults to ~/.cursor/ecc when CURSOR_VERSION is set', () => { + const originalVersion = process.env.CURSOR_VERSION; + const originalHome = process.env.ECC_AGENT_DATA_HOME; + try { + delete process.env.ECC_AGENT_DATA_HOME; + process.env.CURSOR_VERSION = 'test-cursor'; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + const reloaded = require('../../scripts/lib/utils'); + const expected = path.join(reloaded.getHomeDir(), '.cursor', 'ecc'); + assert.strictEqual(reloaded.getAgentDataHome(), expected); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + delete require.cache[require.resolve('../../scripts/lib/agent-data-home')]; + if (originalVersion === undefined) { + delete process.env.CURSOR_VERSION; + } else { + process.env.CURSOR_VERSION = originalVersion; + } + if (originalHome === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = originalHome; + } + } + })) passed++; else failed++; + + if (test('getAgentDataHome expands tilde in ECC_AGENT_DATA_HOME', () => { + const original = process.env.ECC_AGENT_DATA_HOME; + try { + process.env.ECC_AGENT_DATA_HOME = path.join('~', '.cursor', 'ecc-test'); + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const reloaded = require('../../scripts/lib/utils'); + const expected = path.join(reloaded.getHomeDir(), '.cursor', 'ecc-test'); + assert.strictEqual(reloaded.getAgentDataHome(), expected); + } finally { + delete require.cache[require.resolve('../../scripts/lib/utils')]; + if (original === undefined) { + delete process.env.ECC_AGENT_DATA_HOME; + } else { + process.env.ECC_AGENT_DATA_HOME = original; + } + } })) passed++; else failed++; if (test('getSessionSearchDirs includes canonical and legacy paths', () => {