From 4953ec5462ce3fcbfccccda75e186a698ca89496 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Sun, 26 Jul 2026 20:17:05 +0300 Subject: [PATCH] fix(plugin): refresh marketplace and add OMP support --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .omp-plugin/marketplace.json | 21 + CHANGELOG.md | 10 +- README.md | 26 +- internal/version/version.go | 2 +- plugin/engram/.claude-plugin/plugin.json | 2 +- plugin/engram/.codex-plugin/plugin.json | 2 +- plugin/engram/commands/cleanup.md | 86 ---- plugin/engram/commands/doctor.md | 4 + plugin/engram/commands/export.md | 60 --- plugin/engram/commands/restart.md | 20 - plugin/engram/commands/retro.md | 81 ---- plugin/engram/commands/setup.md | 24 +- plugin/engram/commands/stats.md | 4 + plugin/engram/scripts/run-engram.js | 576 ++++++++++++----------- plugin/engram/scripts/run-engram.test.js | 80 ++++ 17 files changed, 456 insertions(+), 548 deletions(-) create mode 100644 .omp-plugin/marketplace.json delete mode 100644 plugin/engram/commands/cleanup.md delete mode 100644 plugin/engram/commands/export.md delete mode 100644 plugin/engram/commands/restart.md delete mode 100644 plugin/engram/commands/retro.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cb802aeb5..3e8a7b4a1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "engram", - "version": "0.5.1", + "version": "6.46.2", "description": "Persistent memory for Claude Code — captures observations, stores knowledge across sessions, injects relevant context automatically", "owner": { "name": "thebtf" @@ -10,7 +10,7 @@ { "name": "engram", "description": "Persistent memory system with PostgreSQL+pgvector backend and MCP integration", - "version": "0.5.1", + "version": "6.46.2", "author": { "name": "thebtf" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 37b022f79..f63892dde 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "engram", - "version": "6.29.0", + "version": "6.46.2", "description": "Persistent memory for Claude Code. v6 BREAKING: per-workstation API tokens replace the shared admin token. Issue a keycard via the dashboard /tokens page after upgrade and paste it via /engram:setup. The operator key (ENGRAM_AUTH_ADMIN_TOKEN) lives ONLY on the server host and MUST NOT be pasted into a workstation.", "author": { "name": "thebtf" diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json new file mode 100644 index 000000000..b51d97cff --- /dev/null +++ b/.omp-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "engram", + "version": "6.46.2", + "description": "Persistent memory for Claude Code and Oh My Pi", + "owner": { + "name": "thebtf" + }, + "plugins": [ + { + "name": "engram", + "description": "Persistent shared memory with MCP integration", + "version": "6.46.2", + "author": { + "name": "thebtf" + }, + "source": "./plugin/engram", + "category": "productivity" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index b4121f196..c430aa9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [6.46.2] - 2026-07-26 + +### Fixed + +- **Marketplace version propagation.** The repository and published marketplace catalogs now derive the Engram plugin version from the canonical plugin manifest, preventing OMP and Claude Code from pinning current plugin content under the stale `4.0.4` cache version. +- **Oh My Pi compatibility.** OMP receives a native `.omp-plugin/marketplace.json`, reuses stable plugin data across versioned marketplace cache slots, and has explicit installation and configuration guidance. Claude lifecycle hooks remain Claude-only; OMP loads the MCP server, skills, and supported slash commands. Legacy commands that called removed admin actions or server-only operator endpoints are no longer shipped. + ## [6.46.1] - 2026-07-17 ### Fixed @@ -1900,7 +1907,8 @@ Initial release with full feature set. Originally based on [claude-mnemonic](https://github.com/lukaszraczylo/claude-mnemonic) by Lukasz Raczylo. -[Unreleased]: https://github.com/thebtf/engram/compare/v6.46.1...HEAD +[Unreleased]: https://github.com/thebtf/engram/compare/v6.46.2...HEAD +[6.46.2]: https://github.com/thebtf/engram/compare/v6.46.1...v6.46.2 [6.46.1]: https://github.com/thebtf/engram/compare/v6.46.0...v6.46.1 [6.46.0]: https://github.com/thebtf/engram/compare/v6.45.0...v6.46.0 [6.45.0]: https://github.com/thebtf/engram/compare/v6.44.1...v6.45.0 diff --git a/README.md b/README.md index 633438c9b..ac0481c35 100644 --- a/README.md +++ b/README.md @@ -245,22 +245,32 @@ before proxying browser routes. ## Installation -### Plugin Install (recommended) +### Plugin install (recommended) -The plugin registers the MCP server, hooks, and slash commands automatically. +The marketplace plugin registers the MCP server, skills, and slash commands in +Claude Code and Oh My Pi. Claude Code also activates the bundled lifecycle hooks; +OMP 17.x does not execute Claude `hooks.json`, so automatic capture and context +injection remain Claude-only. -```bash -# Set environment variables first -ENGRAM_URL=http://your-server:37777 -ENGRAM_TOKEN=engram_your_workstation_keycard -``` +Install the marketplace plugin first, then run `/engram:setup` to create the +universal `~/.engram/config.json` configuration and restart the host. + +Claude Code: ``` /plugin marketplace add thebtf/engram-marketplace /plugin install engram ``` -Restart Claude Code. Everything is configured. +Oh My Pi: + +```bash +omp plugin marketplace add thebtf/engram-marketplace +omp plugin install engram@engram +``` + +Restart the agent host after configuration. + ### Docker Compose diff --git a/internal/version/version.go b/internal/version/version.go index c06561505..dbb32233b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -2,4 +2,4 @@ package version // Daemon is the version reported by the local stdio MCP daemon to clients and // to the backend server during gRPC initialization. -var Daemon = "v6.46.1" +var Daemon = "v6.46.2" diff --git a/plugin/engram/.claude-plugin/plugin.json b/plugin/engram/.claude-plugin/plugin.json index c6b7f63de..f63892dde 100644 --- a/plugin/engram/.claude-plugin/plugin.json +++ b/plugin/engram/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "engram", - "version": "6.46.1", + "version": "6.46.2", "description": "Persistent memory for Claude Code. v6 BREAKING: per-workstation API tokens replace the shared admin token. Issue a keycard via the dashboard /tokens page after upgrade and paste it via /engram:setup. The operator key (ENGRAM_AUTH_ADMIN_TOKEN) lives ONLY on the server host and MUST NOT be pasted into a workstation.", "author": { "name": "thebtf" diff --git a/plugin/engram/.codex-plugin/plugin.json b/plugin/engram/.codex-plugin/plugin.json index 76f431da3..a4f0e10f0 100644 --- a/plugin/engram/.codex-plugin/plugin.json +++ b/plugin/engram/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "engram", - "version": "6.46.1", + "version": "6.46.2", "description": "Persistent shared memory for Codex. Requires ENGRAM_URL and ENGRAM_TOKEN from a dashboard-issued worker keycard; the server operator key must stay on the server host.", "author": { "name": "thebtf" diff --git a/plugin/engram/commands/cleanup.md b/plugin/engram/commands/cleanup.md deleted file mode 100644 index 4c09a7b83..000000000 --- a/plugin/engram/commands/cleanup.md +++ /dev/null @@ -1,86 +0,0 @@ -# Memory Cleanup - -Interactive review and curation of low-quality observations. Helps keep the memory system lean and accurate. - -## Instructions - -### 1. Fetch Quality Report - -Call: -``` -Tool: admin(action="quality") -``` - -This returns observations sorted by quality score. Focus on observations with quality < 0.5. - -### 2. Fetch Merge Candidates - -Call: -``` -Tool: admin(action="consolidations") -``` - -This returns groups of similar observations that could be merged. - -### 3. Present Low-Quality Observations - -For each low-quality observation (quality < 0.5), present to the user: - -``` -Observation #{id} — Quality: {score}/1.0 - Title: {title} - Type: {type} - Age: {days} days - Scope: {scope} - Injected: {injection_count} times - Used: {success_count} times - - Suggestions: {improvement_suggestions from quality report} - - Actions: - [K] Keep as-is - [S] Suppress (hide from search, reversible) - [E] Edit (improve title/narrative) - [M] Merge with similar observation - [skip] Skip to next -``` - -Wait for user to choose an action. - -### 4. Execute Actions - -Based on user choice: - -- **Keep**: No action, proceed to next -- **Suppress**: `feedback(action="suppress", id={id})` -- **Edit**: Ask user for updated title/narrative, then `store(action="edit", id={id}, title="...", narrative="...")` -- **Merge**: Show merge candidates for this observation. If user selects a target: `store(action="merge", source_id={id}, target_id={target})` -- **Skip**: Proceed to next - -### 5. Present Merge Candidates - -After individual review, show merge groups from step 2: - -``` -Merge Candidates (similar observations): - Group 1: - - #{id1}: "{title1}" (quality: {q1}) - - #{id2}: "{title2}" (quality: {q2}) - Similarity: {similarity}% - - [M] Merge (keep #{id1}, supersede #{id2}) - [skip] Skip -``` - -### 6. Report Summary - -``` -Engram Cleanup Results: - Reviewed: N observations - Suppressed: M - Edited: K - Merged: J - Kept: L - - Quality improvement: {before_avg} → {after_avg} (estimated) -``` diff --git a/plugin/engram/commands/doctor.md b/plugin/engram/commands/doctor.md index 27e00646c..e13452df1 100644 --- a/plugin/engram/commands/doctor.md +++ b/plugin/engram/commands/doctor.md @@ -1,3 +1,7 @@ +--- +description: Diagnose Engram MCP connectivity and subsystem health +--- + # Engram Doctor Diagnose Engram connectivity and health. Tests the actual MCP connection, not just environment variables. diff --git a/plugin/engram/commands/export.md b/plugin/engram/commands/export.md deleted file mode 100644 index d3c93b92a..000000000 --- a/plugin/engram/commands/export.md +++ /dev/null @@ -1,60 +0,0 @@ -# Export Observations - -Export engram observations in human-readable or machine-readable format. - -## Instructions - -### 1. Ask Export Parameters - -Prompt the user for: - -- **Scope**: all projects or specific project (default: current project) -- **Format**: markdown (default), json, jsonl -- **Type filter**: all types or specific type (decision, bugfix, feature, etc.) -- **Output**: console (default) or save to file - -If the user provides no arguments, use defaults: current project, markdown, all types, console output. - -### 2. Execute Export - -Call: -``` -Tool: admin(action="export", project="{project}", format="{format}") -``` - -If type filter specified, pass the type parameter as well. - -### 3. Format Output - -#### Markdown format (default) -The export tool returns formatted markdown. Display it directly. - -If saving to file, write to `{project}-engram-export-{date}.md`. - -#### JSON format -Display raw JSON. If saving to file, write to `{project}-engram-export-{date}.json`. - -#### JSONL format -Display one observation per line. If saving to file, write to `{project}-engram-export-{date}.jsonl`. - -### 4. Large Export Warning - -If the export contains more than 100 observations, warn before displaying: - -``` -Export contains {count} observations ({estimated_lines} lines). -Display in console or save to file? - [C] Console (may be long) - [F] Save to file: {suggested_filename} -``` - -### 5. Report Summary - -``` -Engram Export: - Observations: {count} - Format: {format} - Scope: {project or "all"} - Type: {type or "all"} - Output: {console or file_path} -``` diff --git a/plugin/engram/commands/restart.md b/plugin/engram/commands/restart.md deleted file mode 100644 index d0f0123ac..000000000 --- a/plugin/engram/commands/restart.md +++ /dev/null @@ -1,20 +0,0 @@ -# Restart Engram Worker - -Restart the Engram worker process. Use when experiencing issues with the memory system. - -## Instructions - -1. Call `check_system_health` to verify current connection and get server info. - -2. If connected, report current status and ask the user if they want to proceed with restart. - -3. The restart endpoint is on the same server as MCP. If `ENGRAM_URL` is set, derive the base URL from it (strip `/mcp` path). Otherwise, ask the user for the server address. - -4. Call the restart endpoint: - ```bash - curl -X POST /api/restart -H "Authorization: Bearer ${ENGRAM_AUTH_ADMIN_TOKEN}" - ``` - -5. Wait 2 seconds, then call `check_system_health` again to verify the worker restarted. - -6. Report the result, including the version number. diff --git a/plugin/engram/commands/retro.md b/plugin/engram/commands/retro.md deleted file mode 100644 index ce82581f8..000000000 --- a/plugin/engram/commands/retro.md +++ /dev/null @@ -1,81 +0,0 @@ -# Session Retrospective - -Analyze what engram injected during this session, what was useful, and what should be improved. - -## Instructions - -### 1. Get Session Injection Data - -First, determine the current session's DB ID. Call: - -``` -Tool: check_system_health() -``` - -Then fetch injected observations for this session using the REST API: - -```bash -curl -s -H "Authorization: Bearer ${ENGRAM_AUTH_ADMIN_TOKEN}" \ - "${ENGRAM_URL%/mcp}/api/sessions/${SESSION_ID}/injections" -``` - -If session ID is not available, use the `/api/sessions/list` endpoint to find the most recent session. - -### 2. Display Injection Analysis - -For each injected observation, show: - -| # | Title | Type | Section | Effectiveness | Status | -|---|-------|------|---------|--------------|--------| -| 1 | {title} | {type} | {injection_section} | {effectiveness_score}% | {used/ignored} | - -Group by injection section (always_inject, recent, relevant). - -### 3. Effectiveness Summary - -Fetch system-wide effectiveness: - -```bash -curl -s -H "Authorization: Bearer ${ENGRAM_AUTH_ADMIN_TOKEN}" \ - "${ENGRAM_URL%/mcp}/api/learning/effectiveness-distribution" -``` - -Display: -``` -Session Effectiveness: - High: N observations (X%) - Medium: N observations (X%) - Low: N observations (X%) - Insufficient: N observations (needs more data) - -Learning Trend: [improving/stable/declining] -``` - -For learning trend, fetch: -```bash -curl -s -H "Authorization: Bearer ${ENGRAM_AUTH_ADMIN_TOKEN}" \ - "${ENGRAM_URL%/mcp}/api/learning/curve" -``` - -### 4. Recommendations - -Based on the analysis: -- **Suppress** observations with effectiveness < 30% and 10+ injections (consistently unhelpful) -- **Boost** observations referenced in agent responses (confirmed useful) -- **Note** observations with "insufficient data" — these need more sessions - -For each recommendation, ask the user if they want to act: -- Suppress: `feedback(action="suppress", id=N)` -- Rate useful: `feedback(action="rate", id=N, rating="useful")` -- Rate not useful: `feedback(action="rate", id=N, rating="not_useful")` - -### 5. Report Summary - -``` -Engram Retro Results: -- Observations injected: N -- Used by agent: M (X%) -- Ignored: K (Y%) -- High effectiveness: H -- Recommendations: R actions suggested -``` diff --git a/plugin/engram/commands/setup.md b/plugin/engram/commands/setup.md index 561c1b8c0..cdc0171ef 100644 --- a/plugin/engram/commands/setup.md +++ b/plugin/engram/commands/setup.md @@ -1,3 +1,7 @@ +--- +description: Configure Engram for Claude Code, Oh My Pi, or Codex +--- + # Engram Setup (v6 — two-tier token model) Configure the connection to your Engram server. @@ -10,7 +14,7 @@ Configure the connection to your Engram server. > If you are upgrading from v5.x: you MUST issue a fresh keycard via the > dashboard before this session can authenticate. See step 2 below. -## Codex setup +## OMP and Codex setup > **Note (Codex ≥ 0.139):** Codex stopped forwarding > `[shell_environment_policy.set]` values to plugin MCP server children in @@ -40,9 +44,9 @@ chmod 600 ~/.engram/config.json On Windows the file lives in your user profile; NTFS ACLs inherited from the parent directory already restrict access to your account. -The engram plugin reads this file as the final fallback in its credential -chain, so it works in Codex, Claude Code, and any other harness that does -not forward env vars to plugin children. +The engram plugin reads this file as the final fallback, so it works in OMP, +Codex, Claude Code, and any other harness that does not forward environment +variables to plugin children. ### Legacy path (Codex < 0.139 only) @@ -130,14 +134,14 @@ it there triggers a v6 warning at daemon startup. If the user has a stale `ENGRAM_API_TOKEN` entry, remove it too (v5-era name, no longer read). -For Codex, create `~/.engram/config.json` as shown in "Codex setup" above. -The config file also works as a universal fallback for any harness that does not -forward env vars to plugin children. +For OMP and Codex, create `~/.engram/config.json` as shown in "OMP and Codex +setup" above. The config file also works as a universal fallback for any harness +that does not forward environment variables to plugin children. ### 4. Restart the agent host > Settings are only read when the agent host starts. Please **close and reopen -> Claude Code/Codex or start a new Codex thread** for the changes to take +> Claude Code or OMP, or start a new Codex thread** for the changes to take > effect. The plugin wrapper exits non-zero when `ENGRAM_URL` or > `ENGRAM_TOKEN` is missing, so you'll see a clear error rather than silent > partial-tool degradation. @@ -208,3 +212,7 @@ Set it the same way you set credentials for your harness: Truthy values: `true` (boolean) or the strings `1`/`true`/`yes`/`on` (case-insensitive); unset or anything else leaves hooks fully active. Reversible — remove the var/key to restore. Explicit env always wins over the config file. + +OMP 17.x loads the MCP server, skills, and slash commands from the marketplace, +but does not execute Claude `hooks.json`; quiet mode therefore has no hook effect +under OMP. diff --git a/plugin/engram/commands/stats.md b/plugin/engram/commands/stats.md index 057e1c222..098d58527 100644 --- a/plugin/engram/commands/stats.md +++ b/plugin/engram/commands/stats.md @@ -1,3 +1,7 @@ +--- +description: Display Engram memory health and usage statistics +--- + # Memory Statistics Display engram memory system health and analytics at a glance. diff --git a/plugin/engram/scripts/run-engram.js b/plugin/engram/scripts/run-engram.js index 23f384748..b43ca6818 100644 --- a/plugin/engram/scripts/run-engram.js +++ b/plugin/engram/scripts/run-engram.js @@ -5,173 +5,189 @@ const { spawnSync } = require("child_process"); const path = require("path"); const fs = require("fs"); +const os = require("os"); const STARTUP_DIAGNOSTIC_LOG_MAX_BYTES = 128 * 1024; function main() { - const pluginRoot = resolvePluginRoot(); - const pluginData = resolvePluginData(pluginRoot); - - const ext = process.platform === "win32" ? ".exe" : ""; - const binaryPath = path.join(pluginData, "bin", `engram${ext}`); - const ensureBinary = path.join(pluginRoot, "scripts", "ensure-binary.js"); - - const configFilePath = resolveConfigFilePath(pluginData); - const configFile = readEngramConfigFile(configFilePath); - - emitStartupDiagnostic(pluginData, configFilePath, configFile); - - if (fs.existsSync(ensureBinary)) { - // ensure-binary owns freshness: it compares plugin.json with both the - // marker file and the binary's own --version output. - const ensureStatus = checkedSpawnSync(process.execPath, [ensureBinary], { - stdio: "inherit", - env: { - ...process.env, - PLUGIN_ROOT: pluginRoot, - PLUGIN_DATA: pluginData, - CLAUDE_PLUGIN_ROOT: process.env.CLAUDE_PLUGIN_ROOT || pluginRoot, - CLAUDE_PLUGIN_DATA: process.env.CLAUDE_PLUGIN_DATA || pluginData, - }, - }, "ensure-binary"); - if (ensureStatus !== 0) { - process.exit(ensureStatus); - } + const pluginRoot = resolvePluginRoot(); + const pluginData = resolvePluginData(pluginRoot); + + const ext = process.platform === "win32" ? ".exe" : ""; + const binaryPath = path.join(pluginData, "bin", `engram${ext}`); + const ensureBinary = path.join(pluginRoot, "scripts", "ensure-binary.js"); + + const configFilePath = resolveConfigFilePath(pluginData); + const configFile = readEngramConfigFile(configFilePath); + + emitStartupDiagnostic(pluginData, configFilePath, configFile); + + if (fs.existsSync(ensureBinary)) { + // ensure-binary owns freshness: it compares plugin.json with both the + // marker file and the binary's own --version output. + const ensureStatus = checkedSpawnSync(process.execPath, [ensureBinary], { + stdio: "inherit", + env: { + ...process.env, + PLUGIN_ROOT: pluginRoot, + PLUGIN_DATA: pluginData, + CLAUDE_PLUGIN_ROOT: process.env.CLAUDE_PLUGIN_ROOT || pluginRoot, + CLAUDE_PLUGIN_DATA: process.env.CLAUDE_PLUGIN_DATA || pluginData, + }, + }, "ensure-binary"); + if (ensureStatus !== 0) { + process.exit(ensureStatus); } + } - if (!fs.existsSync(binaryPath)) { - process.stderr.write( - `[engram] binary not found at ${binaryPath}. The plugin could not install the client binary. ` + - "Check network access to GitHub Releases and reinstall or upgrade the plugin.\n" - ); - process.exit(1); - } - - // Visible diagnostic: fail early if the workstation is not configured. A new - // install should not expose half-working tools with no remote memory backend. - // - // Resolution order for each credential: - // 1. Explicit env vars (ENGRAM_URL / ENGRAM_TOKEN) - // 2. Claude Code plugin option env (CLAUDE_PLUGIN_OPTION_*) - // 3. Legacy userConfig env aliases (ENGRAM_CLAUDE_USERCONFIG_*) - // 4. Config file fallback (ENGRAM_CONFIG_FILE, /config.json, - // or ~/.engram/config.json) — added in v6.4.15 to handle Codex ≥0.139 - // which stopped forwarding shell_environment_policy.set values to plugin - // MCP server children (openai/codex#24401). - const serverURL = - configuredEnvValue( - "ENGRAM_URL", - "ENGRAM_SERVER_URL", - // Claude Code exports plugin userConfig values to plugin subprocesses as - // CLAUDE_PLUGIN_OPTION_. Interpolating ${user_config.*} inside the - // .mcp.json env block is NOT used: it silently prevents the MCP server - // from spawning (anthropics/claude-code#51573). - "CLAUDE_PLUGIN_OPTION_server_url", - "CLAUDE_PLUGIN_OPTION_SERVER_URL", - "ENGRAM_CLAUDE_USERCONFIG_URL" - ) || - (configFile && isConfiguredValue(configFile.server_url) ? configFile.server_url : ""); - if (!serverURL) { - process.stderr.write( - "[engram] FATAL: ENGRAM_URL is empty. Configure Engram before first use.\n" + - "Universal (all harnesses): create ~/.engram/config.json with {\"server_url\":\"http://...\",\"api_token\":\"engram_...\"}\n" + - " or set ENGRAM_CONFIG_FILE to a custom path.\n" + - "Claude Code: run /engram:setup or set ENGRAM_URL in ~/.claude/settings.json env.\n" + - `Config file checked: ${configFilePath}\n` - ); - process.exit(1); - } - process.env.ENGRAM_URL = serverURL; - - // v6 model: ENGRAM_TOKEN is the per-workstation keycard issued via the - // dashboard /tokens page. The operator key (ENGRAM_AUTH_ADMIN_TOKEN) lives - // ONLY on the server host and MUST NOT be set on a workstation. - const token = - configuredEnvValue( - "ENGRAM_TOKEN", - "CLAUDE_PLUGIN_OPTION_api_token", - "CLAUDE_PLUGIN_OPTION_API_TOKEN", - "ENGRAM_CLAUDE_USERCONFIG_TOKEN" - ) || - (configFile && isConfiguredValue(configFile.api_token) ? configFile.api_token : ""); - if (!token) { - process.stderr.write( - `[engram] FATAL: ENGRAM_TOKEN is empty. Open ${serverURL.replace(/\/+$/, "")}/tokens, ` + - "generate a workstation keycard, then configure ENGRAM_TOKEN.\n" + - "Universal (all harnesses): add \"api_token\":\"engram_...\" to the config file.\n" + - `Config file checked: ${configFilePath}\n` - ); - process.exit(1); - } - process.env.ENGRAM_TOKEN = token; - - if (process.env.ENGRAM_AUTH_ADMIN_TOKEN) { - process.stderr.write( - "[engram] WARN: ENGRAM_AUTH_ADMIN_TOKEN is set on this workstation. v6 forbids " + - "this — the operator key belongs ONLY on the server host. Remove it from " + - "your local agent config and use ENGRAM_TOKEN with a dashboard-issued keycard.\n" - ); - } + if (!fs.existsSync(binaryPath)) { + process.stderr.write( + `[engram] binary not found at ${binaryPath}. The plugin could not install the client binary. ` + + "Check network access to GitHub Releases and reinstall or upgrade the plugin.\n" + ); + process.exit(1); + } + + // Visible diagnostic: fail early if the workstation is not configured. A new + // install should not expose half-working tools with no remote memory backend. + // + // Resolution order for each credential: + // 1. Explicit env vars (ENGRAM_URL / ENGRAM_TOKEN) + // 2. Claude Code plugin option env (CLAUDE_PLUGIN_OPTION_*) + // 3. Legacy userConfig env aliases (ENGRAM_CLAUDE_USERCONFIG_*) + // 4. Config file fallback (ENGRAM_CONFIG_FILE, /config.json, + // or ~/.engram/config.json) — added in v6.4.15 to handle Codex ≥0.139 + // which stopped forwarding shell_environment_policy.set values to plugin + // MCP server children (openai/codex#24401). + const serverURL = + configuredEnvValue( + "ENGRAM_URL", + "ENGRAM_SERVER_URL", + // Claude Code exports plugin userConfig values to plugin subprocesses as + // CLAUDE_PLUGIN_OPTION_. Interpolating ${user_config.*} inside the + // .mcp.json env block is NOT used: it silently prevents the MCP server + // from spawning (anthropics/claude-code#51573). + "CLAUDE_PLUGIN_OPTION_server_url", + "CLAUDE_PLUGIN_OPTION_SERVER_URL", + "ENGRAM_CLAUDE_USERCONFIG_URL" + ) || + (configFile && isConfiguredValue(configFile.server_url) ? configFile.server_url : ""); + if (!serverURL) { + process.stderr.write( + "[engram] FATAL: ENGRAM_URL is empty. Configure Engram before first use.\n" + + "Universal (all harnesses): create ~/.engram/config.json with {\"server_url\":\"http://...\",\"api_token\":\"engram_...\"}\n" + + " or set ENGRAM_CONFIG_FILE to a custom path.\n" + + "Claude Code: run /engram:setup or set ENGRAM_URL in ~/.claude/settings.json env.\n" + + `Config file checked: ${configFilePath}\n` + ); + process.exit(1); + } + process.env.ENGRAM_URL = serverURL; + + // v6 model: ENGRAM_TOKEN is the per-workstation keycard issued via the + // dashboard /tokens page. The operator key (ENGRAM_AUTH_ADMIN_TOKEN) lives + // ONLY on the server host and MUST NOT be set on a workstation. + const token = + configuredEnvValue( + "ENGRAM_TOKEN", + "CLAUDE_PLUGIN_OPTION_api_token", + "CLAUDE_PLUGIN_OPTION_API_TOKEN", + "ENGRAM_CLAUDE_USERCONFIG_TOKEN" + ) || + (configFile && isConfiguredValue(configFile.api_token) ? configFile.api_token : ""); + if (!token) { + process.stderr.write( + `[engram] FATAL: ENGRAM_TOKEN is empty. Open ${serverURL.replace(/\/+$/, "")}/tokens, ` + + "generate a workstation keycard, then configure ENGRAM_TOKEN.\n" + + "Universal (all harnesses): add \"api_token\":\"engram_...\" to the config file.\n" + + `Config file checked: ${configFilePath}\n` + ); + process.exit(1); + } + process.env.ENGRAM_TOKEN = token; + const childEnv = childEnvForEngram(process.env); + + if (process.env.ENGRAM_AUTH_ADMIN_TOKEN) { + process.stderr.write( + "[engram] WARN: ENGRAM_AUTH_ADMIN_TOKEN is set on this workstation. v6 forbids " + + "this — the operator key belongs ONLY on the server host. Remove it from " + + "your local agent config and use ENGRAM_TOKEN with a dashboard-issued keycard.\n" + ); + } + + // Run the engram binary as a child process and propagate its exit code. + const status = checkedSpawnSync(binaryPath, process.argv.slice(2), { + stdio: "inherit", + env: childEnv, + }, "engram exec"); + process.exit(status); +} - // Replace this process with the engram binary - const status = checkedSpawnSync(binaryPath, process.argv.slice(2), { - stdio: "inherit", - env: process.env, - }, "engram exec"); - process.exit(status); +function childEnvForEngram(env = process.env) { + const childEnv = { ...env }; + delete childEnv.ENGRAM_AUTH_ADMIN_TOKEN; + return childEnv; } function resolvePluginRoot() { - return ( - process.env.PLUGIN_ROOT || - process.env.CLAUDE_PLUGIN_ROOT || - path.resolve(__dirname, "..") - ); + return ( + configuredEnvValue("PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT") || + path.resolve(__dirname, "..") + ); } function resolvePluginData(pluginRoot) { - const configured = configuredEnvValue("PLUGIN_DATA", "CLAUDE_PLUGIN_DATA"); - if (configured) { - return configured; - } + const configured = configuredEnvValue("PLUGIN_DATA", "CLAUDE_PLUGIN_DATA"); + if (configured) { + return configured; + } - const codexData = inferCodexPluginDataDir(pluginRoot); - if (codexData) { - return codexData; - } + const codexData = inferCodexPluginDataDir(pluginRoot); + if (codexData) { + return codexData; + } - return path.join(pluginRoot, ".data"); + return path.join(pluginRoot, ".data"); } function inferCodexPluginDataDir(pluginRoot) { - const resolved = path.resolve(pluginRoot); - const parsed = path.parse(resolved); - const relative = resolved.slice(parsed.root.length); - const parts = relative.split(path.sep).filter(Boolean); - const cacheIndex = parts.lastIndexOf("cache"); - - if ( - cacheIndex < 1 || - parts[cacheIndex - 1] !== "plugins" || - parts.length < cacheIndex + 4 - ) { - return ""; - } + const resolved = path.resolve(pluginRoot); + const parsed = path.parse(resolved); + const relative = resolved.slice(parsed.root.length); + const parts = relative.split(path.sep).filter(Boolean); + const cacheIndex = parts.lastIndexOf("cache"); - const marketplace = parts[cacheIndex + 1]; - const pluginName = parts[cacheIndex + 2]; + if (cacheIndex < 1 || parts[cacheIndex - 1] !== "plugins") { + return ""; + } + + // OMP stores marketplace plugins in cache/plugins/______. + // Keep mutable data outside the versioned cache so upgrades reuse the binary and config. + const ompCacheSlot = (parts[cacheIndex + 1] === "plugins" ? parts[cacheIndex + 2] : "") || ""; + const ompMatch = ompCacheSlot.match(/^(.+?)___(.+?)___(.+)$/); + if (ompMatch && parts.length === cacheIndex + 3) { const pluginDataRoot = path.join(parsed.root, ...parts.slice(0, cacheIndex), "data"); - return path.join(pluginDataRoot, `${marketplace}-${pluginName}`); + return path.join(pluginDataRoot, `${ompMatch[1]}-${ompMatch[2]}`); + } + + if (parts.length < cacheIndex + 4) { + return ""; + } + + const marketplace = parts[cacheIndex + 1]; + const pluginName = parts[cacheIndex + 2]; + const pluginDataRoot = path.join(parsed.root, ...parts.slice(0, cacheIndex), "data"); + return path.join(pluginDataRoot, `${marketplace}-${pluginName}`); } function configuredEnvValue(...keys) { - for (const key of keys) { - const value = process.env[key]; - if (isConfiguredValue(value)) { - return value.trim(); - } + for (const key of keys) { + const value = process.env[key]; + if (isConfiguredValue(value)) { + return value.trim(); } - return ""; + } + return ""; } /** @@ -186,17 +202,17 @@ function configuredEnvValue(...keys) { * ~/.engram/config.json (the documented Codex setup path) are found. */ function resolveConfigFilePath(pluginData) { - const explicit = process.env.ENGRAM_CONFIG_FILE; - if (isConfiguredValue(explicit)) { - return explicit.trim(); - } - if (pluginData && typeof pluginData === "string" && pluginData.trim()) { - const candidate = path.join(pluginData.trim(), "config.json"); - if (fs.existsSync(candidate)) { - return candidate; - } + const explicit = process.env.ENGRAM_CONFIG_FILE; + if (isConfiguredValue(explicit)) { + return explicit.trim(); + } + if (pluginData && typeof pluginData === "string" && pluginData.trim()) { + const candidate = path.join(pluginData.trim(), "config.json"); + if (fs.existsSync(candidate)) { + return candidate; } - return path.join(require("os").homedir(), ".engram", "config.json"); + } + return path.join(os.homedir(), ".engram", "config.json"); } /** @@ -206,165 +222,169 @@ function resolveConfigFilePath(pluginData) { * Never throws. */ function readEngramConfigFile(configFilePath) { - try { - if (!configFilePath || !fs.existsSync(configFilePath)) { - return null; - } - const raw = fs.readFileSync(configFilePath, "utf8"); - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - return { - server_url: typeof parsed.server_url === "string" ? parsed.server_url.trim() : "", - api_token: typeof parsed.api_token === "string" ? parsed.api_token.trim() : "", - }; - } catch { - // Missing file, permission error, or malformed JSON — skip silently. - return null; + try { + if (!configFilePath || !fs.existsSync(configFilePath)) { + return null; + } + const raw = fs.readFileSync(configFilePath, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; } + return { + server_url: typeof parsed.server_url === "string" ? parsed.server_url.trim() : "", + api_token: typeof parsed.api_token === "string" ? parsed.api_token.trim() : "", + }; + } catch { + // Missing file, permission error, or malformed JSON — skip silently. + return null; + } } function isConfiguredValue(value) { - if (typeof value !== "string") { - return false; - } - const trimmed = value.trim(); - if (!trimmed) { - return false; - } - return !/^\$\{[^}]+\}$/.test(trimmed); + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + return !/^\$\{[^}]+\}$/.test(trimmed); } function emitStartupDiagnostic(pluginData, configFilePath, configFile) { - const line = formatStartupDiagnostic(process.env, configFilePath, configFile); - process.stderr.write(`${line}\n`); - appendStartupDiagnosticLog(pluginData, line); + const line = formatStartupDiagnostic(process.env, configFilePath, configFile); + process.stderr.write(`${line}\n`); + appendStartupDiagnosticLog(pluginData, line); } function formatStartupDiagnostic(env = process.env, configFilePath, configFile) { - const keys = [ - ["ENGRAM_URL", false], - ["ENGRAM_TOKEN", true], - ["ENGRAM_SERVER_URL", false], - ["CLAUDE_PLUGIN_OPTION_server_url", false], - ["CLAUDE_PLUGIN_OPTION_api_token", true], - ["ENGRAM_CLAUDE_USERCONFIG_URL", false], - ["ENGRAM_CLAUDE_USERCONFIG_TOKEN", true], - ["PLUGIN_ROOT", false], - ["CLAUDE_PLUGIN_ROOT", false], - ["PLUGIN_DATA", false], - ["CLAUDE_PLUGIN_DATA", false], - ]; - const envParts = keys.map(([key, sensitive]) => describeEnvValue(key, env, sensitive)).join("; "); - const cfPart = describeConfigFile(configFilePath, configFile); - return `[engram] startup env: ${envParts}; ${cfPart}`; + const keys = [ + ["ENGRAM_URL", false], + ["ENGRAM_TOKEN", true], + ["ENGRAM_SERVER_URL", false], + ["CLAUDE_PLUGIN_OPTION_server_url", false], + ["CLAUDE_PLUGIN_OPTION_SERVER_URL", false], + ["CLAUDE_PLUGIN_OPTION_api_token", true], + ["CLAUDE_PLUGIN_OPTION_API_TOKEN", true], + ["ENGRAM_CLAUDE_USERCONFIG_URL", false], + ["ENGRAM_CLAUDE_USERCONFIG_TOKEN", true], + ["ENGRAM_CONFIG_FILE", false], + ["PLUGIN_ROOT", false], + ["CLAUDE_PLUGIN_ROOT", false], + ["PLUGIN_DATA", false], + ["CLAUDE_PLUGIN_DATA", false], + ]; + const envParts = keys.map(([key, sensitive]) => describeEnvValue(key, env, sensitive)).join("; "); + const cfPart = describeConfigFile(configFilePath, configFile); + return `[engram] startup env: ${envParts}; ${cfPart}`; } function describeConfigFile(configFilePath, configFile) { - if (!configFilePath) { - return "config_file=unresolved"; - } - if (!fs.existsSync(configFilePath)) { - return `config_file=missing(${configFilePath})`; - } - if (configFile === null || configFile === undefined) { - return `config_file=malformed(${configFilePath})`; - } - return `config_file=present(${configFilePath})`; + if (!configFilePath) { + return "config_file=unresolved"; + } + if (!fs.existsSync(configFilePath)) { + return `config_file=missing(${configFilePath})`; + } + if (configFile === null || configFile === undefined) { + return `config_file=malformed(${configFilePath})`; + } + return `config_file=present(${configFilePath})`; } function describeEnvValue(key, env = process.env, sensitive = false) { - const raw = env[key]; - if (typeof raw !== "string") { - return `${key}=missing`; - } - - const value = raw.trim(); - if (!value) { - return `${key}=empty`; - } - if (/^\$\{[^}]+\}$/.test(value)) { - return `${key}=placeholder`; - } - - const kind = sensitive ? "redacted" : "present"; - return `${key}=${kind}(len=${value.length})`; + const raw = env[key]; + if (typeof raw !== "string") { + return `${key}=missing`; + } + + const value = raw.trim(); + if (!value) { + return `${key}=empty`; + } + if (/^\$\{[^}]+\}$/.test(value)) { + return `${key}=placeholder`; + } + + const kind = sensitive ? "redacted" : "present"; + return `${key}=${kind}(len=${value.length})`; } function appendStartupDiagnosticLog(pluginData, line, now = new Date()) { - try { - const logsDir = path.join(pluginData, "logs"); - fs.mkdirSync(logsDir, { recursive: true }); - const logPath = path.join(logsDir, "startup-env.log"); - fs.appendFileSync(logPath, `${now.toISOString()} pid=${process.pid} ${line}\n`, "utf8"); - const stat = fs.statSync(logPath); - if (stat.size > 2 * STARTUP_DIAGNOSTIC_LOG_MAX_BYTES) { - trimStartupDiagnosticLog(logPath); - } - } catch { - // Diagnostics must never prevent MCP startup. + try { + const logsDir = path.join(pluginData, "logs"); + fs.mkdirSync(logsDir, { recursive: true }); + const logPath = path.join(logsDir, "startup-env.log"); + fs.appendFileSync(logPath, `${now.toISOString()} pid=${process.pid} ${line}\n`, "utf8"); + const stat = fs.statSync(logPath); + if (stat.size > 2 * STARTUP_DIAGNOSTIC_LOG_MAX_BYTES) { + trimStartupDiagnosticLog(logPath); } + } catch { + // Diagnostics must never prevent MCP startup. + } } function trimStartupDiagnosticLog(logPath, maxBytes = STARTUP_DIAGNOSTIC_LOG_MAX_BYTES) { - try { - const stat = fs.statSync(logPath); - if (stat.size <= maxBytes) { - return; - } - - const content = fs.readFileSync(logPath, "utf8"); - let trimmed = content.slice(-Math.floor(maxBytes / 2)); - const firstNewline = trimmed.indexOf("\n"); - if (firstNewline !== -1) { - trimmed = trimmed.slice(firstNewline + 1); - } - fs.writeFileSync(logPath, trimmed, "utf8"); - } catch { - // Best-effort only. + try { + const stat = fs.statSync(logPath); + if (stat.size <= maxBytes) { + return; + } + + const content = fs.readFileSync(logPath, "utf8"); + let trimmed = content.slice(-Math.floor(maxBytes / 2)); + const firstNewline = trimmed.indexOf("\n"); + if (firstNewline !== -1) { + trimmed = trimmed.slice(firstNewline + 1); } + fs.writeFileSync(logPath, trimmed, "utf8"); + } catch { + // Best-effort only. + } } function checkedSpawnSync(command, args, options, label) { - const result = spawnSync(command, args, options); - const failure = spawnFailureMessage(result, label); - if (failure) { - process.stderr.write(failure); - process.exit(1); - } - return result.status ?? 0; + const result = spawnSync(command, args, options); + const failure = spawnFailureMessage(result, label); + if (failure) { + process.stderr.write(failure); + process.exit(1); + } + return result.status ?? 0; } function spawnFailureMessage(result, label) { - const prefix = `[engram] ${label}`; - if (result && result.error) { - return `${prefix} failed: ${result.error.message}\n`; - } - if (result && result.status === null) { - return `${prefix} terminated by signal ${result.signal || "unknown"}\n`; - } - return ""; + const prefix = `[engram] ${label}`; + if (result && result.error) { + return `${prefix} failed: ${result.error.message}\n`; + } + if (result && result.status === null) { + return `${prefix} terminated by signal ${result.signal || "unknown"}\n`; + } + return ""; } if (require.main === module) { - main(); + main(); } module.exports = { - main, - configuredEnvValue, - appendStartupDiagnosticLog, - describeConfigFile, - describeEnvValue, - emitStartupDiagnostic, - formatStartupDiagnostic, - inferCodexPluginDataDir, - isConfiguredValue, - readEngramConfigFile, - resolveConfigFilePath, - resolvePluginData, - resolvePluginRoot, - spawnFailureMessage, - trimStartupDiagnosticLog, + main, + childEnvForEngram, + configuredEnvValue, + appendStartupDiagnosticLog, + describeConfigFile, + describeEnvValue, + emitStartupDiagnostic, + formatStartupDiagnostic, + inferCodexPluginDataDir, + isConfiguredValue, + readEngramConfigFile, + resolveConfigFilePath, + resolvePluginData, + resolvePluginRoot, + spawnFailureMessage, + trimStartupDiagnosticLog, }; diff --git a/plugin/engram/scripts/run-engram.test.js b/plugin/engram/scripts/run-engram.test.js index e3d5d8cd9..6cd343dd4 100644 --- a/plugin/engram/scripts/run-engram.test.js +++ b/plugin/engram/scripts/run-engram.test.js @@ -7,6 +7,7 @@ const { execFileSync } = require("node:child_process"); const { appendStartupDiagnosticLog, + childEnvForEngram, configuredEnvValue, describeConfigFile, describeEnvValue, @@ -16,6 +17,7 @@ const { readEngramConfigFile, resolveConfigFilePath, resolvePluginData, + resolvePluginRoot, spawnFailureMessage, trimStartupDiagnosticLog, } = require("./run-engram.js"); @@ -67,6 +69,24 @@ test("MCP configs never interpolate user_config in an env block", () => { } }); +test("release-facing plugin and marketplace versions stay aligned", () => { + const repoRoot = path.resolve(__dirname, "..", "..", ".."); + const readJson = (...segments) => JSON.parse(fs.readFileSync(path.join(repoRoot, ...segments), "utf8")); + const claudePlugin = readJson("plugin", "engram", ".claude-plugin", "plugin.json"); + const codexPlugin = readJson("plugin", "engram", ".codex-plugin", "plugin.json"); + const rootPlugin = readJson(".claude-plugin", "plugin.json"); + const claudeMarketplace = readJson(".claude-plugin", "marketplace.json"); + const ompMarketplace = readJson(".omp-plugin", "marketplace.json"); + + assert.equal(claudePlugin.version, "6.46.2"); + assert.equal(codexPlugin.version, claudePlugin.version); + assert.equal(rootPlugin.version, claudePlugin.version); + assert.equal(claudeMarketplace.version, claudePlugin.version); + assert.equal(claudeMarketplace.plugins[0].version, claudePlugin.version); + assert.equal(ompMarketplace.version, claudePlugin.version); + assert.equal(ompMarketplace.plugins[0].version, claudePlugin.version); +}); + test("Codex MCP config launches wrapper when cwd is the plugin root", () => { // Codex spawns the plugin MCP server with cwd resolved to the plugin root // (the .mcp.json "cwd": "."), so the relative entrypoint must resolve there. @@ -163,6 +183,27 @@ test("infers Codex plugin data dir from installed cache root", () => { ); }); +test("infers OMP plugin data dir from installed cache root", () => { + const ompHome = path.join(os.tmpdir(), "omp-home"); + const pluginRoot = path.join( + ompHome, + "plugins", + "cache", + "plugins", + "engram___engram___6.46.2" + ); + + assert.equal( + inferCodexPluginDataDir(pluginRoot), + path.join(ompHome, "plugins", "data", "engram-engram") + ); +}); + +test("OMP cache root without a plugin slot fails open", () => { + const ompCacheRoot = path.join(os.tmpdir(), "omp-home", "plugins", "cache", "plugins"); + assert.equal(inferCodexPluginDataDir(ompCacheRoot), ""); +}); + test("explicit plugin data env takes precedence over inferred Codex path", () => { const previousPluginData = process.env.PLUGIN_DATA; const previousClaudePluginData = process.env.CLAUDE_PLUGIN_DATA; @@ -264,6 +305,9 @@ test("startup diagnostic classifies env values without leaking token contents", ENGRAM_SERVER_URL: "", ENGRAM_CLAUDE_USERCONFIG_URL: "${user_config.server_url}", CLAUDE_PLUGIN_OPTION_api_token: "engram_secret_keycard_value", + CLAUDE_PLUGIN_OPTION_SERVER_URL: "https://uppercase.example.test/mcp", + CLAUDE_PLUGIN_OPTION_API_TOKEN: "engram_uppercase_secret", + ENGRAM_CONFIG_FILE: "/tmp/engram-config.json", }); assert.match(diagnostic, /ENGRAM_URL=present\(len=25\)/); @@ -271,6 +315,10 @@ test("startup diagnostic classifies env values without leaking token contents", assert.match(diagnostic, /ENGRAM_SERVER_URL=empty/); assert.match(diagnostic, /ENGRAM_CLAUDE_USERCONFIG_URL=placeholder/); assert.match(diagnostic, /CLAUDE_PLUGIN_OPTION_api_token=redacted\(len=27\)/); + assert.match(diagnostic, /CLAUDE_PLUGIN_OPTION_SERVER_URL=present\(len=34\)/); + assert.match(diagnostic, /CLAUDE_PLUGIN_OPTION_API_TOKEN=redacted\(len=23\)/); + assert.match(diagnostic, /ENGRAM_CONFIG_FILE=present\(len=23\)/); + assert.doesNotMatch(diagnostic, /engram_uppercase_secret/); assert.doesNotMatch(diagnostic, /engram_secret_keycard_value/); }); @@ -298,6 +346,38 @@ test("wrapper falls back to CLAUDE_PLUGIN_OPTION userConfig env names", () => { } }); +test("child environment drops the server-only operator token", () => { + assert.deepEqual( + childEnvForEngram({ + ENGRAM_AUTH_ADMIN_TOKEN: "operator-secret", + ENGRAM_TOKEN: "worker-keycard", + ENGRAM_URL: "https://engram.example.test/mcp", + }), + { + ENGRAM_TOKEN: "worker-keycard", + ENGRAM_URL: "https://engram.example.test/mcp", + } + ); +}); + +test("resolvePluginRoot ignores unresolved placeholder values", () => { + const previousPluginRoot = process.env.PLUGIN_ROOT; + const previousClaudePluginRoot = process.env.CLAUDE_PLUGIN_ROOT; + + try { + process.env.PLUGIN_ROOT = "${PLUGIN_ROOT}"; + process.env.CLAUDE_PLUGIN_ROOT = "${CLAUDE_PLUGIN_ROOT}"; + assert.equal(resolvePluginRoot(), path.resolve(__dirname, "..")); + + delete process.env.PLUGIN_ROOT; + process.env.CLAUDE_PLUGIN_ROOT = path.join(os.tmpdir(), "engram-plugin-root"); + assert.equal(resolvePluginRoot(), process.env.CLAUDE_PLUGIN_ROOT); + } finally { + restoreEnv("PLUGIN_ROOT", previousPluginRoot); + restoreEnv("CLAUDE_PLUGIN_ROOT", previousClaudePluginRoot); + } +}); + test("describeEnvValue reports missing and placeholder states", () => { assert.equal(describeEnvValue("MISSING", {}), "MISSING=missing"); assert.equal(