diff --git a/platform-integrations/INSTALL_SPEC.md b/platform-integrations/INSTALL_SPEC.md index 3e238c4..26198b9 100644 --- a/platform-integrations/INSTALL_SPEC.md +++ b/platform-integrations/INSTALL_SPEC.md @@ -3,7 +3,7 @@ ## Overview `install.sh` is a single-file bash/Python hybrid installer that sets up Evolve integrations -into a user's project directory for one or more supported platforms: **Bob**, **Roo**, and **Claude**. +into a user's project directory for one or more supported platforms: **Bob**, **Roo**, **Claude**, and **Codex**. It is designed to be run: - Locally from within the evolve repo: `./install.sh install` @@ -48,13 +48,13 @@ Commands: status Show what is currently installed install options: - --platform {bob,roo,claude,all} Platform to install (default: auto-detect + prompt) + --platform {bob,roo,claude,codex,all} Platform to install (default: auto-detect + prompt) --mode {lite,full} Installation mode for bob (default: lite) --dir DIR Target project directory (default: current working dir) --dry-run Preview changes without modifying files uninstall options: - --platform {bob,roo,claude,all} Platform to uninstall (default: prompt) + --platform {bob,roo,claude,codex,all} Platform to uninstall (default: prompt) --dir DIR Target project directory (default: current working dir) --dry-run Preview changes without modifying files ``` @@ -70,6 +70,7 @@ Detection checks in order (any match = platform considered available): | bob | `.bob/` dir exists in target dir, OR `bob` on PATH | | roo | `.roomodes` file exists in target dir, OR `roo` or `roo-code` on PATH | | claude | `.claude/` dir exists in target dir, OR `claude` on PATH | +| codex | `.codex/` dir exists in target dir, OR `.agents/plugins/marketplace.json` exists, OR `codex` on PATH | If no `--platform` flag is given, the script runs interactively: shows detected platforms, lets the user pick one, multiple, or all. @@ -105,7 +106,7 @@ Target: project directory 3. Merge mode entry from `skills/.roomodes` → `.roomodes` in project dir - Target `.roomodes` may be JSON or YAML; detected by trying `json.loads` first - Upsert by `slug: evolve-lite` (JSON: array upsert; YAML: sentinel block) - - If target does not exist, create as JSON + - If target does not exist, create as YAML ### Claude — Lite Mode @@ -118,6 +119,24 @@ Source: `platform-integrations/claude/plugins/evolve-lite/` ``` 3. No file-system fallback for Claude (plugin system manages its own state) +### Codex — Lite Mode + +Source: `platform-integrations/codex/plugins/evolve-lite/` +Target: project directory + +1. Copy `platform-integrations/codex/plugins/evolve-lite/` → `plugins/evolve-lite/` in the target project +2. Copy shared lib from `platform-integrations/claude/plugins/evolve-lite/lib/` → `plugins/evolve-lite/lib/` +3. Upsert plugin entry `evolve-lite` into `.agents/plugins/marketplace.json` +4. Upsert a `UserPromptSubmit` hook into `.codex/hooks.json` that runs the Evolve recall helper script by walking upward from the current working directory until it finds `plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py` (does not require `git`) +5. Print post-install guidance that automatic recall requires `~/.codex/config.toml` to include: + ```toml + [features] + codex_hooks = true + ``` +6. Print a manual fallback note that users can invoke `evolve-lite:recall` directly if they do not want to enable Codex hooks + +Codex is currently implemented only in lite mode. Full mode is reserved for future MCP-backed work. + --- ## Uninstall Actions @@ -138,11 +157,16 @@ Source: `platform-integrations/claude/plugins/evolve-lite/` 1. Attempt `claude plugin uninstall evolve-lite` via subprocess 2. If that fails, print manual instructions +### Codex +1. Remove `plugins/evolve-lite/` +2. Remove the `evolve-lite` entry from `.agents/plugins/marketplace.json` +3. Remove the Evolve `UserPromptSubmit` hook from `.codex/hooks.json` + --- ## File Operation Strategies -### JSON Strategy (mcp.json, .roomodes) +### JSON Strategy (mcp.json, .roomodes, marketplace.json, hooks.json) All JSON writes use atomic read-modify-write: 1. Read existing file (or start with `{}` if not found) @@ -150,10 +174,10 @@ All JSON writes use atomic read-modify-write: 3. Write to `.evolve.tmp` 4. `os.replace(tmp, path)` — atomic on POSIX -**Key upsert** (`mcpServers.evolve`): navigate nested keys via `dict.setdefault`, set leaf value. +**Key upsert** (`mcpServers.evolve`, `hooks.UserPromptSubmit` scaffolding): navigate nested keys via `dict.setdefault`, merge matching dict values in place, and only replace scalar/list leaves. -**Array upsert** (`.roomodes` `customModes`): iterate array, find item where `item["slug"] == target_slug`, -replace in-place; append if not found. +**Array upsert** (`.roomodes` `customModes`, `marketplace.json` `plugins`): iterate array, find item where the identity key matches, +merge matching dict items in place; append if not found. **Array remove**: filter array by `item["slug"] != target_slug`, write back. @@ -165,14 +189,14 @@ YAML files use sentinel comment blocks: customModes: - slug: other-mode ... -# >>>evolve-lite<<< +# >>>evolve:evolve-lite<<< - slug: evolve-lite name: Evolve Lite ... -# <<>>evolve-lite<<<` exists in file. If yes, replace the block +**Install**: check if sentinel `# >>>evolve:evolve-lite<<<` exists in file. If yes, replace the block between sentinels. If no, append sentinel block to end of file. **Uninstall**: find sentinel start and end lines, remove all lines between them (inclusive). @@ -190,6 +214,7 @@ All operations are safe to run multiple times: - JSON writes upsert (replace-if-exists, insert-if-not) - YAML writes check for sentinel before appending - Claude plugin install is idempotent by the Claude CLI itself +- Codex marketplace and hook writes merge matching Evolve entries and preserve user-owned entries --- diff --git a/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json new file mode 100644 index 0000000..bd80923 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/.codex-plugin/plugin.json @@ -0,0 +1,29 @@ +{ + "name": "evolve-lite", + "version": "1.0.0", + "description": "Recall and save Evolve entities in Codex without MCP.", + "author": { + "name": "Vinod Muthusamy", + "url": "https://github.com/AgentToolkit/altk-evolve" + }, + "homepage": "https://github.com/AgentToolkit/altk-evolve", + "repository": "https://github.com/AgentToolkit/altk-evolve", + "license": "MIT", + "keywords": ["evolve", "codex", "entities", "memory"], + "skills": "./skills/", + "interface": { + "displayName": "Evolve Lite", + "shortDescription": "Recall and save reusable Evolve entities.", + "longDescription": "A lightweight Codex plugin that helps you save reusable entities from successful sessions and recall them automatically on new prompts.", + "developerName": "AgentToolkit", + "category": "Productivity", + "capabilities": ["Interactive", "Write"], + "websiteURL": "https://github.com/AgentToolkit/altk-evolve", + "defaultPrompt": [ + "Recall Evolve entities for this task.", + "Save new Evolve learnings from this session.", + "Show me the entities stored for this repo." + ], + "brandColor": "#2563EB" + } +} diff --git a/platform-integrations/codex/plugins/evolve-lite/README.md b/platform-integrations/codex/plugins/evolve-lite/README.md new file mode 100644 index 0000000..bea5df7 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/README.md @@ -0,0 +1,69 @@ +# Evolve Lite Plugin for Codex + +Evolve Lite for Codex provides lightweight file-backed learning and recall without MCP. + +## Features + +- Automatic recall through a repo-level Codex `UserPromptSubmit` hook when Codex hooks are enabled +- Manual `learn` skill to save reusable entities into `.evolve/entities/` +- Manual `recall` skill to inspect everything stored for the current repo + +## Storage + +Entities are stored in the active workspace under: + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md + cache-api-responses-locally.md +``` + +Each entity is a markdown file with lightweight YAML frontmatter. + +## Source Layout + +This source tree intentionally omits `lib/`. + +The shared library lives in: + +```text +platform-integrations/claude/plugins/evolve-lite/lib/ +``` + +`platform-integrations/install.sh` copies that shared library into the installed Codex plugin so the installed layout is self-contained. + +## Installation + +Use the platform installer from the repo root: + +```bash +platform-integrations/install.sh install --platform codex +``` + +That installs: + +- `plugins/evolve-lite/` +- `.agents/plugins/marketplace.json` +- `.codex/hooks.json` + +Automatic recall requires Codex hooks to be enabled in `~/.codex/config.toml`: + +```toml +[features] +codex_hooks = true +``` + +If you do not want to enable Codex hooks, you can still invoke the installed `evolve-lite:recall` skill manually to load or inspect the saved guidance for the current repo. + +The installed Codex hook does not require `git`. It walks upward from the current working directory until it finds the repo-local `plugins/evolve-lite/.../retrieve_entities.py` script. + +## Included Skills + +### `learn` + +Analyze the current session and save proactive Evolve entities as markdown files. + +### `recall` + +Show the entities already stored for the current workspace. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md new file mode 100644 index 0000000..f9d6364 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/SKILL.md @@ -0,0 +1,117 @@ +--- +name: learn +description: Extract actionable entities from Codex conversation trajectories. Systematically identifies errors, failures, and inefficiencies to generate proactive entities that prevent them from recurring. +--- + +# Entity Generator + +## Overview + +This skill analyzes the current Codex conversation to extract actionable entities that would help on similar tasks in the future. It **prioritizes errors encountered during the conversation** such as tool failures, exceptions, wrong approaches, and retry loops, then turns them into proactive recommendations that prevent them from recurring. + +## Workflow + +### Step 1: Analyze the Conversation + +Identify from your current conversation: + +- **Task/Request**: What was the user asking for? +- **Steps Taken**: What reasoning, actions, and observations occurred? +- **What Worked**: Which approaches succeeded? +- **What Failed**: Which approaches did not work and why? +- **Errors Encountered**: Tool failures, exceptions, permission errors, retry loops, dead ends, and wrong initial approaches + +### Step 2: Identify Errors and Root Causes + +Scan the conversation for these error signals: + +1. **Tool or command failures**: Non-zero exit codes, error messages, exceptions, stack traces +2. **Permission or access errors**: "Permission denied", "not found", sandbox restrictions +3. **Wrong initial approach**: First attempt abandoned in favor of a different strategy +4. **Retry loops**: Same action attempted multiple times with variations before succeeding +5. **Missing prerequisites**: Missing dependencies, packages, or configs discovered mid-task +6. **Silent failures**: Actions that appeared to succeed but produced wrong results + +For each error found, document: + +| | Error Example | Root Cause | Resolution | Prevention Guideline | +|---|---|---|---|---| +| 1 | `exiftool: command not found` | System tool unavailable in sandbox | Switched to Python PIL | Use PIL for image metadata in sandboxed environments | +| 2 | `git push` rejected (no upstream) | Branch not tracked to remote | Added `-u origin branch` | Always set upstream when pushing a new branch | +| 3 | Tried regex parsing of HTML, got wrong results | Regex cannot handle nested tags | Switched to BeautifulSoup | Use a proper HTML parser, never regex | + +If no errors are found, continue to Step 3 and extract entities from successful patterns. + +### Step 3: Extract Entities + +Extract 3-5 proactive entities. **Prioritize entities derived from errors identified in Step 2.** + +Follow these principles: + +1. **Reframe failures as proactive recommendations** + If an approach failed due to permissions, recommend the alternative first. + +2. **Focus on what worked, stated as the primary approach** + Bad: "If exiftool fails, use PIL instead" + Good: "In sandboxed environments, use Python libraries like PIL or Pillow for image metadata extraction" + +3. **Triggers should be situational context, not failure conditions** + Bad trigger: "When apt-get fails" + Good trigger: "When working in containerized or sandboxed environments" + +4. **For retry loops, recommend the final working approach as the starting point** + If three variations were tried before one worked, the entity should recommend the working variation directly. + +### Step 4: Output Entities JSON + +Output entities in this JSON format: + +```json +{ + "entities": [ + { + "content": "Proactive entity stating what TO DO", + "rationale": "Why this approach works better", + "type": "guideline", + "trigger": "Situational context when this applies" + } + ] +} +``` + +### Step 5: Save Entities + +After generating the entities JSON, save them using the helper script: + +#### Method 1: Direct Pipe + +```bash +echo '' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +#### Method 2: From File + +```bash +cat entities.json | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +#### Method 3: Interactive + +```bash +python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/learn/scripts/save_entities.py" +``` + +The script will: + +- Find or create the entities directory at `.evolve/entities/` +- Write each entity as a markdown file in `{type}/` subdirectories +- Deduplicate against existing entities +- Display confirmation with the total count + +## Best Practices + +1. Prioritize error-derived entities first. +2. Keep entities specific and actionable. +3. Include rationale so the future agent understands why the guidance matters. +4. Use situational triggers instead of failure-based triggers. +5. Limit output to the 3-5 most valuable entities. diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py new file mode 100644 index 0000000..b437e57 --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/learn/scripts/save_entities.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Save Entities Script +Reads entities from stdin JSON and writes each as a markdown file +in the entities directory, organized by type. +""" + +import json +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + _candidate = _ancestor / "lib" + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import ( # noqa: E402 + find_entities_dir, + get_default_entities_dir, + load_all_entities, + write_entity_file, + log as _log, +) + + +def log(message): + _log("save", message) + + +log("Script started") + + +def normalize(text): + """Normalize content for dedup comparison.""" + return " ".join(text.lower().split()) + + +def main(): + try: + input_data = json.load(sys.stdin) + log(f"Received input with keys: {list(input_data.keys())}") + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + print(f"Error: Invalid JSON input - {e}", file=sys.stderr) + sys.exit(1) + + new_entities = input_data.get("entities", []) + if not new_entities: + log("No entities in input") + print("No entities provided in input.", file=sys.stderr) + sys.exit(0) + + log(f"Received {len(new_entities)} new entities") + + entities_dir = find_entities_dir() + if entities_dir: + entities_dir = entities_dir.resolve() + log(f"Found existing dir: {entities_dir}") + print(f"Using existing entities dir: {entities_dir}") + else: + entities_dir = get_default_entities_dir() + log(f"Created new dir: {entities_dir}") + print(f"Created new entities dir: {entities_dir}") + + existing_entities = load_all_entities(entities_dir) + existing_contents = {normalize(e["content"]) for e in existing_entities if e.get("content")} + log(f"Existing entities: {len(existing_entities)}") + + added_count = 0 + for entity in new_entities: + content = entity.get("content") + if not content: + log(f"Skipping entity without content: {entity}") + continue + if normalize(content) in existing_contents: + log(f"Skipping duplicate: {content[:60]}") + continue + + path = write_entity_file(entities_dir, entity) + existing_contents.add(normalize(content)) + added_count += 1 + log(f"Wrote: {path}") + + total = len(existing_entities) + added_count + log(f"Added {added_count} new entities. Total: {total}") + print(f"Added {added_count} new entity(ies). Total: {total}") + print(f"Entities stored in: {entities_dir}") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md new file mode 100644 index 0000000..7cf6bdc --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/SKILL.md @@ -0,0 +1,56 @@ +--- +name: recall +description: Retrieves relevant entities from the local Evolve knowledge base. Designed to be invoked automatically through a Codex UserPromptSubmit hook and manually when you want to inspect saved guidance. +--- + +# Entity Retrieval + +## Overview + +This skill retrieves relevant entities from the local Evolve knowledge base based on the current task context. It loads all stored entities and presents them to Codex as additional developer context. + +## How It Works + +1. If Codex hooks are enabled in `~/.codex/config.toml` with `[features] codex_hooks = true`, the Codex `UserPromptSubmit` hook runs before the prompt is sent. +2. The helper script reads the prompt JSON from stdin. +3. It loads stored entities from `.evolve/entities/`. +4. It prints formatted guidance to stdout. +5. Codex adds that text as extra developer context for the turn. + +## Manual Use + +Run this if you want to inspect the currently stored entities yourself: + +```bash +printf '{"prompt":"Show stored Evolve entities"}' | python3 "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" +``` + +The installed Codex hook itself does not require `git`; it walks upward from the current working directory until it finds the repo-local plugin script. + +If you prefer not to enable Codex hooks, invoke the installed `evolve-lite:recall` skill manually when you want the saved guidance surfaced in the current session. + +## Entities Storage + +Entities are stored as markdown files in `.evolve/entities/`, nested by type: + +```text +.evolve/entities/ + guideline/ + use-context-managers-for-file-operations.md + cache-api-responses-locally.md +``` + +Each file uses markdown with YAML frontmatter: + +```markdown +--- +type: guideline +trigger: When processing files or managing resources +--- + +Use context managers for file operations + +## Rationale + +Ensures proper resource cleanup +``` diff --git a/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py b/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py new file mode 100644 index 0000000..822da0d --- /dev/null +++ b/platform-integrations/codex/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Retrieve and output entities for Codex to use as extra developer context.""" + +import json +import os +import sys +from pathlib import Path + +# Walk up from the script location to find the installed plugin lib directory. +_script = Path(__file__).resolve() +_lib = None +for _ancestor in _script.parents: + _candidate = _ancestor / "lib" + if (_candidate / "entity_io.py").is_file(): + _lib = _candidate + break +if _lib is None: + raise ImportError(f"Cannot find plugin lib directory above {_script}") +sys.path.insert(0, str(_lib)) +from entity_io import find_entities_dir, load_all_entities, log as _log # noqa: E402 + + +def log(message): + _log("retrieve", message) + + +log("Script started") + + +def format_entities(entities): + """Format all entities for Codex to review.""" + header = """## Evolve entities for this task + +Review these stored entities and apply any that are relevant to the user's request: + +""" + items = [] + for entity in entities: + content = entity.get("content") + if not content: + continue + item = f"- **[{entity.get('type', 'general')}]** {content}" + if entity.get("rationale"): + item += f"\n Rationale: {entity['rationale']}" + if entity.get("trigger"): + item += f"\n When: {entity['trigger']}" + items.append(item) + + return header + "\n".join(items) + + +def main(): + try: + input_data = json.load(sys.stdin) + log(f"Input keys: {list(input_data.keys())}") + except json.JSONDecodeError as e: + log(f"Failed to parse JSON input: {e}") + return + + prompt = input_data.get("prompt", "") + if prompt: + log(f"Prompt preview: {prompt[:120]}") + + log("=== Environment Variables ===") + for key, value in sorted(os.environ.items()): + if any(sensitive in key.upper() for sensitive in ["PASSWORD", "SECRET", "TOKEN", "KEY", "API"]): + log(f" {key}=***MASKED***") + else: + log(f" {key}={value}") + log("=== End Environment Variables ===") + + entities_dir = find_entities_dir() + log(f"Entities dir: {entities_dir}") + if not entities_dir: + log("No entities directory found") + return + + entities = load_all_entities(entities_dir) + if not entities: + log("No entities found") + return + + output = format_entities(entities) + print(output) + log(f"Output {len(output)} chars to stdout") + + +if __name__ == "__main__": + main() diff --git a/platform-integrations/install.sh b/platform-integrations/install.sh index 1b7c54d..281c17e 100755 --- a/platform-integrations/install.sh +++ b/platform-integrations/install.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Evolve Platform Installer -# Installs Evolve Lite (and optionally Full) integrations for Bob, Roo, and Claude Code. +# Installs Evolve Lite (and optionally Full) integrations for Bob, Roo, Claude Code, and Codex. # # Usage: -# ./install.sh install [--platform bob|roo|claude|all] [--mode lite|full] [--dir DIR] [--dry-run] -# ./install.sh uninstall [--platform bob|roo|claude|all] [--dir DIR] [--dry-run] +# ./install.sh install [--platform bob|roo|claude|codex|all] [--mode lite|full] [--dir DIR] [--dry-run] +# ./install.sh uninstall [--platform bob|roo|claude|codex|all] [--dir DIR] [--dry-run] # ./install.sh status [--dir DIR] # # Remote: @@ -125,6 +125,7 @@ resolve_source exec python3 - "$SOURCE_DIR" "$@" <<'PYEOF' import argparse +import copy import json import os import re @@ -143,6 +144,7 @@ DRY_RUN = False # set to True by --dry-run flag; checked in all write primitiv BOB_SLUG = "evolve-lite" ROO_SLUG = "evolve-lite" CLAUDE_PLUGIN = "evolve-lite" +CODEX_PLUGIN = "evolve-lite" # ── Colour helpers ──────────────────────────────────────────────────────────── @@ -258,13 +260,25 @@ def remove_file(path): # ── JSON config helpers ──────────────────────────────────────────────────────── +def merge_json_value(existing, desired): + """Recursively merge JSON-like values, preserving unknown keys from existing objects.""" + if isinstance(existing, dict) and isinstance(desired, dict): + merged = copy.deepcopy(existing) + for key, desired_value in desired.items(): + merged[key] = merge_json_value(merged.get(key), desired_value) + return merged + return copy.deepcopy(desired) + + def upsert_json_key(path, key_path: list, value): """Upsert a nested key into a JSON file. key_path = ['a', 'b', 'c'] → data['a']['b']['c'] = value.""" data = read_json(path) cursor = data for key in key_path[:-1]: - cursor = cursor.setdefault(key, {}) - cursor[key_path[-1]] = value + if not isinstance(cursor.get(key), dict): + cursor[key] = {} + cursor = cursor[key] + cursor[key_path[-1]] = merge_json_value(cursor.get(key_path[-1]), value) atomic_write_json(path, data) @@ -288,10 +302,10 @@ def upsert_json_array_item(path, array_key: str, item: dict, id_key: str): arr = data.setdefault(array_key, []) for i, existing in enumerate(arr): if existing.get(id_key) == item.get(id_key): - arr[i] = item + arr[i] = merge_json_value(existing, item) break else: - arr.append(item) + arr.append(copy.deepcopy(item)) atomic_write_json(path, data) @@ -305,6 +319,195 @@ def remove_json_array_item(path, array_key: str, id_key: str, id_val: str): atomic_write_json(path, data) +def _default_codex_marketplace(): + return { + "name": "evolve-local", + "interface": { + "displayName": "Evolve Local Plugins", + }, + "plugins": [], + } + + +def upsert_codex_marketplace_entry(path, item): + """Upsert a Codex marketplace plugin entry by name.""" + data = read_json(path) + if not data: + data = _default_codex_marketplace() + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object.") + + interface = data.setdefault("interface", {}) + if not isinstance(interface, dict): + interface = {} + data["interface"] = interface + data.setdefault("name", "evolve-local") + interface.setdefault("displayName", "Evolve Local Plugins") + + plugins = data.setdefault("plugins", []) + if not isinstance(plugins, list): + raise ValueError(f"{path} field 'plugins' must be an array.") + + for index, existing in enumerate(plugins): + if isinstance(existing, dict) and existing.get("name") == item.get("name"): + plugins[index] = merge_json_value(existing, item) + break + else: + plugins.append(copy.deepcopy(item)) + + atomic_write_json(path, data) + + +def _codex_recall_hook_command(): + return ( + "sh -lc '" + 'd=\"$PWD\"; ' + "while :; do " + 'candidate=\"$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py\"; ' + 'if [ -f \"$candidate\" ]; then EVOLVE_ENTITIES_DIR=\"$d/.evolve/entities\" exec python3 \"$candidate\"; fi; ' + '[ \"$d\" = \"/\" ] && break; ' + 'd=\"$(dirname \"$d\")\"; ' + "done; " + "exit 1'" + ) + + +def _is_codex_recall_command(command): + return isinstance(command, str) and "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in command + + +def _codex_recall_hook(): + return { + "type": "command", + "command": _codex_recall_hook_command(), + "statusMessage": "Loading Evolve guidance", + } + + +def _codex_recall_hook_group(): + return { + "matcher": "", + "hooks": [_codex_recall_hook()], + } + + +def _iter_group_hooks(group): + hooks = group.get("hooks", []) + if isinstance(hooks, list): + return hooks + if isinstance(hooks, dict): + return hooks.values() + return [] + + +def _group_contains_codex_recall_command(group): + return any(isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")) for hook in _iter_group_hooks(group)) + + +def _upsert_codex_recall_hook_into_group(group): + updated_group = copy.deepcopy(group) + recall_hook = _codex_recall_hook() + hooks = updated_group.get("hooks") + + if isinstance(hooks, list): + for index, existing_hook in enumerate(hooks): + if isinstance(existing_hook, dict) and _is_codex_recall_command(existing_hook.get("command")): + hooks[index] = merge_json_value(existing_hook, recall_hook) + break + else: + hooks.append(copy.deepcopy(recall_hook)) + return updated_group + + if isinstance(hooks, dict): + for key, existing_hook in hooks.items(): + if isinstance(existing_hook, dict) and _is_codex_recall_command(existing_hook.get("command")): + hooks[key] = merge_json_value(existing_hook, recall_hook) + break + else: + hooks["evolve-lite"] = copy.deepcopy(recall_hook) + return updated_group + + updated_group["hooks"] = [copy.deepcopy(recall_hook)] + return updated_group + + +def _remove_codex_recall_hook_from_group(group): + updated_group = copy.deepcopy(group) + hooks = updated_group.get("hooks") + + if isinstance(hooks, list): + updated_group["hooks"] = [ + hook + for hook in hooks + if not (isinstance(hook, dict) and _is_codex_recall_command(hook.get("command"))) + ] + return updated_group + + if isinstance(hooks, dict): + updated_group["hooks"] = { + key: hook + for key, hook in hooks.items() + if not (isinstance(hook, dict) and _is_codex_recall_command(hook.get("command"))) + } + return updated_group + + return updated_group + + +def upsert_codex_user_prompt_hook(path, group): + """Upsert the Evolve UserPromptSubmit hook into a Codex hooks.json file.""" + data = read_json(path) + if not data: + data = {"hooks": {}} + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object.") + + hooks = data.setdefault("hooks", {}) + if not isinstance(hooks, dict): + hooks = {} + data["hooks"] = hooks + + groups = hooks.setdefault("UserPromptSubmit", []) + if not isinstance(groups, list): + groups = [] + hooks["UserPromptSubmit"] = groups + + for index, existing in enumerate(groups): + if isinstance(existing, dict) and _group_contains_codex_recall_command(existing): + groups[index] = _upsert_codex_recall_hook_into_group(existing) + break + else: + groups.append(copy.deepcopy(group)) + + atomic_write_json(path, data) + + +def remove_codex_user_prompt_hook(path): + """Remove the Evolve UserPromptSubmit hook from a Codex hooks.json file.""" + if not os.path.isfile(str(path)): + return + + data = read_json(path) + hooks = data.get("hooks") + if not isinstance(hooks, dict): + return + + groups = hooks.get("UserPromptSubmit", []) + if not isinstance(groups, list): + return + + hooks["UserPromptSubmit"] = [ + _remove_codex_recall_hook_from_group(group) + if isinstance(group, dict) and _group_contains_codex_recall_command(group) + else group + for group in groups + ] + if not hooks["UserPromptSubmit"]: + hooks.pop("UserPromptSubmit", None) + + atomic_write_json(path, data) + + # ── YAML helpers ─────────────────────────────────────────────────────────────── def _sentinel_start(slug): return f"# >>>evolve:{slug}<<<" @@ -455,6 +658,11 @@ def detect_platforms(target_dir): shutil.which("claude") is not None or (target / ".claude").is_dir() ), + "codex": ( + shutil.which("codex") is not None or + (target / ".codex").is_dir() or + (target / ".agents" / "plugins" / "marketplace.json").is_file() + ), } @@ -733,6 +941,89 @@ def status_claude(target_dir): print(f" evolve-lite plugin : ? (could not query)") +# ── Codex installer ─────────────────────────────────────────────────────────── + +def install_codex(source_dir, target_dir): + plugin_source = Path(source_dir) / "platform-integrations" / "codex" / "plugins" / CODEX_PLUGIN + plugin_target = Path(target_dir) / "plugins" / CODEX_PLUGIN + info(f"Installing Codex → {plugin_target}") + + copy_tree(plugin_source, plugin_target) + success("Copied Codex plugin") + + shared_lib = Path(source_dir) / "platform-integrations" / "claude" / "plugins" / "evolve-lite" / "lib" + if not shared_lib.is_dir(): + error(f"Shared lib not found: {shared_lib} — is the Claude plugin present in the source tree?") + sys.exit(1) + copy_tree(shared_lib, plugin_target / "lib") + success("Copied Codex lib") + + marketplace_target = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" + upsert_codex_marketplace_entry( + marketplace_target, + { + "name": CODEX_PLUGIN, + "source": { + "source": "local", + "path": f"./plugins/{CODEX_PLUGIN}", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + }, + ) + success(f"Upserted Codex marketplace entry in {marketplace_target}") + + hooks_target = Path(target_dir) / ".codex" / "hooks.json" + upsert_codex_user_prompt_hook(hooks_target, _codex_recall_hook_group()) + success(f"Upserted Codex UserPromptSubmit hook in {hooks_target}") + warn("Automatic Codex recall requires hooks to be enabled in ~/.codex/config.toml:") + print(" [features]") + print(" codex_hooks = true") + info("If you do not want to enable Codex hooks, invoke the installed evolve-lite:recall skill manually.") + + success("Codex installation complete") + + +def uninstall_codex(target_dir): + info(f"Uninstalling Codex from {target_dir}") + + remove_dir(Path(target_dir) / "plugins" / CODEX_PLUGIN) + remove_json_array_item(Path(target_dir) / ".agents" / "plugins" / "marketplace.json", "plugins", "name", CODEX_PLUGIN) + remove_codex_user_prompt_hook(Path(target_dir) / ".codex" / "hooks.json") + + success("Codex uninstall complete") + + +def status_codex(target_dir): + plugin_dir = Path(target_dir) / "plugins" / CODEX_PLUGIN + print(" Codex:") + print(f" plugins/evolve-lite : {'✓' if plugin_dir.is_dir() else '✗'}") + print(f" lib/entity_io.py : {'✓' if (plugin_dir / 'lib' / 'entity_io.py').is_file() else '✗'}") + print(f" skills/learn : {'✓' if (plugin_dir / 'skills' / 'learn').is_dir() else '✗'}") + print(f" skills/recall : {'✓' if (plugin_dir / 'skills' / 'recall').is_dir() else '✗'}") + + marketplace_path = Path(target_dir) / ".agents" / "plugins" / "marketplace.json" + marketplace_present = False + if marketplace_path.is_file(): + data = read_json(marketplace_path) + marketplace_present = any(entry.get("name") == CODEX_PLUGIN for entry in data.get("plugins", [])) + print(f" marketplace.json entry : {'✓' if marketplace_present else '✗'}") + + hooks_path = Path(target_dir) / ".codex" / "hooks.json" + hook_present = False + if hooks_path.is_file(): + data = read_json(hooks_path) + hook_groups = data.get("hooks", {}).get("UserPromptSubmit", []) + hook_present = any( + isinstance(group, dict) and _group_contains_codex_recall_command(group) + for group in hook_groups + ) + print(f" .codex/hooks.json entry : {'✓' if hook_present else '✗'}") + + # ── Dispatch ────────────────────────────────────────────────────────────────── def cmd_install(args): @@ -742,7 +1033,7 @@ def cmd_install(args): # Resolve platforms if args.platform == "all": - platforms = ["bob", "roo", "claude"] + platforms = ["bob", "roo", "claude", "codex"] elif args.platform: platforms = [args.platform] else: @@ -767,6 +1058,8 @@ def cmd_install(args): install_roo(SOURCE_DIR, target_dir) elif platform == "claude": install_claude(SOURCE_DIR, target_dir) + elif platform == "codex": + install_codex(SOURCE_DIR, target_dir) except Exception as e: error(f"Failed to install {platform}: {e}") if EVOLVE_DEBUG: @@ -794,7 +1087,7 @@ def cmd_uninstall(args): info(_c("35", "DRY RUN — no files will be written or deleted")) if args.platform == "all": - platforms = ["bob", "roo", "claude"] + platforms = ["bob", "roo", "claude", "codex"] elif args.platform: platforms = [args.platform] else: @@ -811,6 +1104,8 @@ def cmd_uninstall(args): uninstall_roo(target_dir) elif platform == "claude": uninstall_claude(target_dir) + elif platform == "codex": + uninstall_codex(target_dir) except Exception as e: error(f"Failed to uninstall {platform}: {e}") errors.append(platform) @@ -837,6 +1132,8 @@ def cmd_status(args): print() status_claude(target_dir) print() + status_codex(target_dir) + print() # ── argparse ────────────────────────────────────────────────────────────────── @@ -844,14 +1141,14 @@ def cmd_status(args): def main(): parser = argparse.ArgumentParser( prog="install.sh", - description="Install Evolve integrations for Bob, Roo, and Claude Code.", + description="Install Evolve integrations for Bob, Roo, Claude Code, and Codex.", ) sub = parser.add_subparsers(dest="command", required=True) # install p_install = sub.add_parser("install", help="Install Evolve into the current project") p_install.add_argument( - "--platform", choices=["bob", "roo", "claude", "all"], default=None, + "--platform", choices=["bob", "roo", "claude", "codex", "all"], default=None, help="Platform to install (default: auto-detect and prompt)", ) p_install.add_argument( @@ -870,7 +1167,7 @@ def main(): # uninstall p_uninstall = sub.add_parser("uninstall", help="Remove Evolve from the current project") p_uninstall.add_argument( - "--platform", choices=["bob", "roo", "claude", "all"], default=None, + "--platform", choices=["bob", "roo", "claude", "codex", "all"], default=None, help="Platform to uninstall (default: prompt)", ) p_uninstall.add_argument( diff --git a/tests/platform_integrations/conftest.py b/tests/platform_integrations/conftest.py index 5239751..39d81ee 100644 --- a/tests/platform_integrations/conftest.py +++ b/tests/platform_integrations/conftest.py @@ -84,7 +84,7 @@ def run( Args: command: Command to run (install, uninstall, status) - platform: Platform to target (bob, roo, claude, all, or None for interactive) + platform: Platform to target (bob, roo, claude, codex, all, or None for interactive) mode: Mode for bob (lite, full) dry_run: Whether to use --dry-run flag expect_success: Whether to expect the command to succeed @@ -314,6 +314,28 @@ def create_existing_mcp_config(project_dir: Path): mcp_file.write_text(json.dumps(data, indent=2) + "\n") return mcp_file + @staticmethod + def create_existing_mcp_config_with_evolve(project_dir: Path): + """Create an mcp.json with a user-customized evolve server entry.""" + mcp_file = project_dir / ".bob" / "mcp.json" + mcp_file.parent.mkdir(parents=True, exist_ok=True) + + data = { + "mcpServers": { + "my-server": {"command": "node", "args": ["server.js"], "disabled": False}, + "evolve": { + "command": "python3", + "args": ["old_evolve.py"], + "disabled": True, + "env": {"EVOLVE_PROFILE": "local"}, + "metadata": {"managedBy": "user"}, + }, + } + } + + mcp_file.write_text(json.dumps(data, indent=2) + "\n") + return mcp_file + class RooFixtures: """Helper class to create Roo platform test fixtures.""" @@ -352,6 +374,35 @@ def create_existing_roomodes_json(project_dir: Path): roomodes_file.write_text(json.dumps(data, indent=2) + "\n") return roomodes_file + @staticmethod + def create_existing_roomodes_json_with_evolve(project_dir: Path): + """Create a JSON .roomodes file that already contains a customized evolve-lite mode.""" + roomodes_file = project_dir / ".roomodes" + + data = { + "customModes": [ + { + "slug": "my-roo-mode", + "name": "My Roo Mode", + "roleDefinition": "This is my custom Roo mode.", + "customInstructions": "Follow my Roo instructions.", + "groups": ["read", "edit"], + }, + { + "slug": "evolve-lite", + "name": "My Evolve Lite", + "roleDefinition": "Old evolve role definition.", + "customInstructions": "Old evolve instructions.", + "groups": ["read"], + "metadata": {"accent": "teal"}, + "shortcuts": ["recall-first"], + }, + ] + } + + roomodes_file.write_text(json.dumps(data, indent=2) + "\n") + return roomodes_file + @staticmethod def create_existing_roomodes_yaml(project_dir: Path): """Create a .roomodes file in YAML format with a custom mode.""" @@ -372,6 +423,192 @@ def create_existing_roomodes_yaml(project_dir: Path): return roomodes_file +class CodexFixtures: + """Helper class to create Codex platform test fixtures.""" + + @staticmethod + def create_existing_plugin(project_dir: Path, plugin_name: str = "my-codex-plugin"): + """Create a custom plugin in plugins/.""" + plugin_dir = project_dir / "plugins" / plugin_name / ".codex-plugin" + plugin_dir.mkdir(parents=True, exist_ok=True) + (plugin_dir / "plugin.json").write_text( + json.dumps( + { + "name": plugin_name, + "version": "0.1.0", + "description": "Custom user plugin", + "skills": "./skills/", + }, + indent=2, + ) + + "\n" + ) + return plugin_dir.parent + + @staticmethod + def create_existing_marketplace(project_dir: Path): + """Create a marketplace.json with a user's existing Codex plugin entry.""" + marketplace_file = project_dir / ".agents" / "plugins" / "marketplace.json" + marketplace_file.parent.mkdir(parents=True, exist_ok=True) + marketplace_file.write_text( + json.dumps( + { + "name": "custom-local", + "interface": { + "displayName": "Custom Local Plugins", + }, + "plugins": [ + { + "name": "my-codex-plugin", + "source": { + "source": "local", + "path": "./plugins/my-codex-plugin", + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": "Productivity", + } + ], + }, + indent=2, + ) + + "\n" + ) + return marketplace_file + + @staticmethod + def create_existing_hooks(project_dir: Path): + """Create a .codex/hooks.json with a user's existing hooks.""" + hooks_file = project_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True, exist_ok=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/session_start.py", + "statusMessage": "Loading notes", + } + ], + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/custom_prompt_memory.py", + "statusMessage": "Loading custom memory", + } + ] + } + ], + } + }, + indent=2, + ) + + "\n" + ) + return hooks_file + + @staticmethod + def create_existing_hooks_with_shared_evolve_group(project_dir: Path): + """Create a list-based UserPromptSubmit group containing both user hooks and the evolve hook.""" + hooks_file = project_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True, exist_ok=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "src/.*", + "hooks": [ + { + "type": "command", + "command": "python3 ~/.codex/hooks/custom_prompt_memory.py", + "statusMessage": "Loading custom memory", + }, + { + "type": "command", + "command": ( + "sh -lc '" + 'd="$PWD"; ' + "while :; do " + 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; ' + '[ "$d" = "/" ] && break; ' + 'd="$(dirname "$d")"; ' + "done; " + "exit 1'" + ), + "statusMessage": "Old evolve guidance", + "delayMs": 250, + }, + ], + } + ] + } + }, + indent=2, + ) + + "\n" + ) + return hooks_file + + @staticmethod + def create_existing_hooks_with_dict_evolve_group(project_dir: Path): + """Create a dict-based UserPromptSubmit group containing user hooks and the evolve hook.""" + hooks_file = project_dir / ".codex" / "hooks.json" + hooks_file.parent.mkdir(parents=True, exist_ok=True) + hooks_file.write_text( + json.dumps( + { + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "src/.*", + "hooks": { + "memory": { + "type": "command", + "command": "python3 ~/.codex/hooks/custom_prompt_memory.py", + "statusMessage": "Loading custom memory", + }, + "evolve-lite": { + "type": "command", + "command": ( + "sh -lc '" + 'd="$PWD"; ' + "while :; do " + 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; ' + '[ "$d" = "/" ] && break; ' + 'd="$(dirname "$d")"; ' + "done; " + "exit 1'" + ), + "statusMessage": "Old evolve guidance", + "delayMs": 250, + }, + }, + } + ] + } + }, + indent=2, + ) + + "\n" + ) + return hooks_file + + @pytest.fixture def bob_fixtures(): """Provide Bob platform test fixtures.""" @@ -382,3 +619,9 @@ def bob_fixtures(): def roo_fixtures(): """Provide Roo platform test fixtures.""" return RooFixtures() + + +@pytest.fixture +def codex_fixtures(): + """Provide Codex platform test fixtures.""" + return CodexFixtures() diff --git a/tests/platform_integrations/test_codex.py b/tests/platform_integrations/test_codex.py new file mode 100644 index 0000000..57dd82f --- /dev/null +++ b/tests/platform_integrations/test_codex.py @@ -0,0 +1,166 @@ +""" +Tests for the Codex platform integration installer behavior. +""" + +import json + +import pytest + + +EVOLVE_PLUGIN = "evolve-lite" +EVOLVE_HOOK_SNIPPET = "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" + + +def _marketplace_has_evolve_plugin(path): + data = json.loads(path.read_text()) + return any(entry.get("name") == EVOLVE_PLUGIN for entry in data.get("plugins", [])) + + +def _hooks_have_evolve_recall(path): + data = json.loads(path.read_text()) + groups = data.get("hooks", {}).get("UserPromptSubmit", []) + for group in groups: + for hook in _iter_group_hooks(group): + if EVOLVE_HOOK_SNIPPET in hook.get("command", ""): + return group.get("matcher") == "" + return False + + +def _iter_group_hooks(group): + hooks = group.get("hooks", []) + if isinstance(hooks, list): + return hooks + if isinstance(hooks, dict): + return list(hooks.values()) + return [] + + +@pytest.mark.platform_integrations +class TestCodexInstall: + """Test the Codex install flow.""" + + def test_install_creates_expected_files(self, temp_project_dir, install_runner, file_assertions): + """Installing Codex should create the plugin tree, marketplace entry, and hook.""" + result = install_runner.run("install", platform="codex") + + plugin_dir = temp_project_dir / "plugins" / EVOLVE_PLUGIN + file_assertions.assert_dir_exists(plugin_dir) + file_assertions.assert_file_exists(plugin_dir / ".codex-plugin" / "plugin.json") + file_assertions.assert_file_exists(plugin_dir / "README.md") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") + file_assertions.assert_dir_exists(plugin_dir / "skills" / "recall") + file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "scripts" / "save_entities.py") + file_assertions.assert_file_exists(plugin_dir / "skills" / "recall" / "scripts" / "retrieve_entities.py") + file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") + + marketplace_path = temp_project_dir / ".agents" / "plugins" / "marketplace.json" + file_assertions.assert_valid_json(marketplace_path) + assert _marketplace_has_evolve_plugin(marketplace_path), "Evolve plugin entry missing from marketplace.json" + + hooks_path = temp_project_dir / ".codex" / "hooks.json" + file_assertions.assert_valid_json(hooks_path) + assert _hooks_have_evolve_recall(hooks_path), "Evolve recall hook missing from .codex/hooks.json" + + hooks_data = json.loads(hooks_path.read_text()) + evolve_groups = [ + group + for group in hooks_data.get("hooks", {}).get("UserPromptSubmit", []) + if any(EVOLVE_HOOK_SNIPPET in hook.get("command", "") for hook in group.get("hooks", [])) + ] + assert evolve_groups[0]["matcher"] == "" + evolve_hook = next(hook for hook in evolve_groups[0]["hooks"] if EVOLVE_HOOK_SNIPPET in hook.get("command", "")) + expected_command = ( + "sh -lc '" + 'd="$PWD"; ' + "while :; do " + 'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; ' + 'if [ -f "$candidate" ]; then EVOLVE_ENTITIES_DIR="$d/.evolve/entities" exec python3 "$candidate"; fi; ' + '[ "$d" = "/" ] && break; ' + 'd="$(dirname "$d")"; ' + "done; " + "exit 1'" + ) + assert evolve_hook["command"] == expected_command + assert "~/.codex/config.toml" in result.stdout + assert "codex_hooks = true" in result.stdout + assert "evolve-lite:recall" in result.stdout + + def test_install_preserves_matching_user_prompt_group(self, temp_project_dir, install_runner, codex_fixtures): + """Installing should merge the evolve hook into an existing matching list-based group.""" + hooks_path = codex_fixtures.create_existing_hooks_with_shared_evolve_group(temp_project_dir) + + install_runner.run("install", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + prompt_groups = hooks_data["hooks"]["UserPromptSubmit"] + assert len(prompt_groups) == 1 + + merged_group = prompt_groups[0] + assert merged_group["matcher"] == "src/.*" + + custom_hooks = [ + hook for hook in _iter_group_hooks(merged_group) if hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + ] + assert len(custom_hooks) == 1, "Custom prompt hook was removed from the shared group" + + evolve_hooks = [hook for hook in _iter_group_hooks(merged_group) if EVOLVE_HOOK_SNIPPET in hook.get("command", "")] + assert len(evolve_hooks) == 1, "Evolve hook was duplicated or removed from the shared group" + assert evolve_hooks[0]["statusMessage"] == "Loading Evolve guidance" + assert evolve_hooks[0]["delayMs"] == 250 + + def test_install_updates_dict_based_matching_group(self, temp_project_dir, install_runner, codex_fixtures): + """Installing should update a dict-based matching group without adding a replacement group.""" + hooks_path = codex_fixtures.create_existing_hooks_with_dict_evolve_group(temp_project_dir) + + install_runner.run("install", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + prompt_groups = hooks_data["hooks"]["UserPromptSubmit"] + assert len(prompt_groups) == 1 + + merged_group = prompt_groups[0] + assert merged_group["matcher"] == "src/.*" + assert isinstance(merged_group["hooks"], dict) + assert "memory" in merged_group["hooks"] + assert "evolve-lite" in merged_group["hooks"] + + evolve_hook = merged_group["hooks"]["evolve-lite"] + assert EVOLVE_HOOK_SNIPPET in evolve_hook["command"] + assert evolve_hook["statusMessage"] == "Loading Evolve guidance" + assert evolve_hook["delayMs"] == 250 + + def test_uninstall_removes_only_evolve_hook_from_matching_group(self, temp_project_dir, install_runner, codex_fixtures): + """Uninstalling should remove only the evolve hook entry and preserve the shared group.""" + hooks_path = codex_fixtures.create_existing_hooks_with_dict_evolve_group(temp_project_dir) + + install_runner.run("uninstall", platform="codex") + + hooks_data = json.loads(hooks_path.read_text()) + prompt_groups = hooks_data["hooks"]["UserPromptSubmit"] + assert len(prompt_groups) == 1 + + remaining_group = prompt_groups[0] + assert remaining_group["matcher"] == "src/.*" + assert isinstance(remaining_group["hooks"], dict) + assert "memory" in remaining_group["hooks"] + assert "evolve-lite" not in remaining_group["hooks"] + assert all(EVOLVE_HOOK_SNIPPET not in hook.get("command", "") for hook in _iter_group_hooks(remaining_group)) + + def test_codex_dry_run_does_not_write_files(self, temp_project_dir, install_runner): + """Dry-run should report actions without writing files.""" + result = install_runner.run("install", platform="codex", dry_run=True) + + assert "DRY RUN" in result.stdout + assert not (temp_project_dir / "plugins" / EVOLVE_PLUGIN).exists() + assert not (temp_project_dir / ".agents" / "plugins" / "marketplace.json").exists() + assert not (temp_project_dir / ".codex" / "hooks.json").exists() + + def test_status_reports_codex_installation(self, temp_project_dir, install_runner): + """Status should show the Codex installation state.""" + install_runner.run("install", platform="codex") + result = install_runner.run("status") + + assert "Codex:" in result.stdout + assert "plugins/evolve-lite" in result.stdout + assert "marketplace.json entry" in result.stdout + assert ".codex/hooks.json entry" in result.stdout diff --git a/tests/platform_integrations/test_idempotency.py b/tests/platform_integrations/test_idempotency.py index e6899a7..16cd569 100644 --- a/tests/platform_integrations/test_idempotency.py +++ b/tests/platform_integrations/test_idempotency.py @@ -153,6 +153,59 @@ def test_install_creates_yaml_when_no_roomodes(self, temp_project_dir, install_r assert first_content == second_content, ".roomodes changed after second install" +@pytest.mark.platform_integrations +class TestCodexIdempotency: + """Test that Codex installation is idempotent.""" + + def test_multiple_installs(self, temp_project_dir, install_runner, file_assertions): + """Running install twice for Codex should be safe.""" + install_runner.run("install", platform="codex") + + marketplace_file = temp_project_dir / ".agents" / "plugins" / "marketplace.json" + hooks_file = temp_project_dir / ".codex" / "hooks.json" + first_marketplace = json.loads(marketplace_file.read_text()) + first_hooks = json.loads(hooks_file.read_text()) + + install_runner.run("install", platform="codex") + + second_marketplace = json.loads(marketplace_file.read_text()) + second_hooks = json.loads(hooks_file.read_text()) + + assert first_marketplace == second_marketplace, "marketplace.json changed after second install" + assert first_hooks == second_hooks, ".codex/hooks.json changed after second install" + + evolve_plugins = [entry for entry in second_marketplace["plugins"] if entry["name"] == "evolve-lite"] + assert len(evolve_plugins) == 1, "Duplicate evolve-lite marketplace entries found" + + prompt_hooks = second_hooks["hooks"]["UserPromptSubmit"] + evolve_hook_groups = [ + group + for group in prompt_hooks + if any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for hook in group.get("hooks", []) + ) + ] + assert len(evolve_hook_groups) == 1, "Duplicate Evolve UserPromptSubmit hooks found" + assert evolve_hook_groups[0].get("matcher") == "" + + def test_install_after_partial_uninstall(self, temp_project_dir, install_runner, file_assertions): + """Installing after deleting part of the Codex plugin should restore it.""" + install_runner.run("install", platform="codex") + + plugin_dir = temp_project_dir / "plugins" / "evolve-lite" + + import shutil + + shutil.rmtree(plugin_dir / "skills" / "learn") + + install_runner.run("install", platform="codex") + + file_assertions.assert_dir_exists(plugin_dir / "skills" / "learn") + file_assertions.assert_file_exists(plugin_dir / "skills" / "learn" / "SKILL.md") + file_assertions.assert_file_exists(plugin_dir / "lib" / "entity_io.py") + + @pytest.mark.platform_integrations class TestUninstallInstallCycle: """Test that uninstall followed by install works correctly.""" @@ -221,3 +274,54 @@ def test_roo_uninstall_install_cycle(self, temp_project_dir, install_runner, roo # Assert: User content still intact file_assertions.assert_dir_exists(roo_dir / "skills" / "my-roo-skill") assert any(m["slug"] == "my-roo-mode" for m in data["customModes"]) + + def test_codex_uninstall_install_cycle(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Uninstalling and reinstalling Codex should work correctly.""" + custom_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + marketplace_file = codex_fixtures.create_existing_marketplace(temp_project_dir) + hooks_file = codex_fixtures.create_existing_hooks(temp_project_dir) + + plugin_json = custom_plugin / ".codex-plugin" / "plugin.json" + original_plugin_content = plugin_json.read_text() + + install_runner.run("install", platform="codex") + + evolve_plugin_dir = temp_project_dir / "plugins" / "evolve-lite" + file_assertions.assert_dir_exists(evolve_plugin_dir) + + install_runner.run("uninstall", platform="codex") + + file_assertions.assert_dir_not_exists(evolve_plugin_dir) + current_marketplace = json.loads(marketplace_file.read_text()) + assert all(entry["name"] != "evolve-lite" for entry in current_marketplace["plugins"]) + + current_hooks = json.loads(hooks_file.read_text()) + prompt_hooks = current_hooks["hooks"].get("UserPromptSubmit", []) + evolve_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + ] + assert not evolve_hooks, "Evolve hook still present after uninstall" + + install_runner.run("install", platform="codex") + + file_assertions.assert_dir_exists(evolve_plugin_dir) + file_assertions.assert_file_unchanged(plugin_json, original_plugin_content) + + reinstalled_marketplace = json.loads(marketplace_file.read_text()) + assert any(entry["name"] == "my-codex-plugin" for entry in reinstalled_marketplace["plugins"]) + assert any(entry["name"] == "evolve-lite" for entry in reinstalled_marketplace["plugins"]) + + reinstalled_hooks = json.loads(hooks_file.read_text()) + assert any( + hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + for group in reinstalled_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) + assert any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for group in reinstalled_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) diff --git a/tests/platform_integrations/test_preservation.py b/tests/platform_integrations/test_preservation.py index a9b61bd..7b60918 100644 --- a/tests/platform_integrations/test_preservation.py +++ b/tests/platform_integrations/test_preservation.py @@ -88,6 +88,21 @@ def test_preserves_existing_mcp_servers(self, temp_project_dir, install_runner, current_data = json.loads(mcp_file.read_text()) assert current_data["mcpServers"]["my-server"] == original_data["mcpServers"]["my-server"] + def test_preserves_existing_evolve_mcp_server_fields(self, temp_project_dir, install_runner, bob_fixtures, file_assertions): + """Install evolve full mode when evolve already exists - custom fields on that entry must be preserved.""" + mcp_file = bob_fixtures.create_existing_mcp_config_with_evolve(temp_project_dir) + + install_runner.run("install", platform="bob", mode="full") + + file_assertions.assert_valid_json(mcp_file) + current_data = json.loads(mcp_file.read_text()) + evolve_server = current_data["mcpServers"]["evolve"] + + assert evolve_server["command"] == "uv" + assert evolve_server["disabled"] is False + assert evolve_server["env"] == {"EVOLVE_PROFILE": "local"} + assert evolve_server["metadata"] == {"managedBy": "user"} + def test_preserves_all_bob_content_together_lite(self, temp_project_dir, install_runner, bob_fixtures, file_assertions): """Install evolve lite mode when user has all types of Bob content - all must be preserved.""" # Setup: Create all types of user content @@ -187,6 +202,20 @@ def test_preserves_existing_roomodes_json(self, temp_project_dir, install_runner evolve_modes = [m for m in current_data["customModes"] if m["slug"] == "evolve-lite"] assert len(evolve_modes) == 1, f"Evolve mode not added. Found {len(evolve_modes)} evolve-lite entries" + def test_preserves_existing_evolve_roomodes_fields(self, temp_project_dir, install_runner, roo_fixtures, file_assertions): + """Install evolve when evolve-lite already exists in JSON .roomodes - custom fields on that mode must be preserved.""" + roomodes_file = roo_fixtures.create_existing_roomodes_json_with_evolve(temp_project_dir) + + install_runner.run("install", platform="roo") + + file_assertions.assert_valid_json(roomodes_file) + current_data = json.loads(roomodes_file.read_text()) + evolve_modes = [m for m in current_data["customModes"] if m["slug"] == "evolve-lite"] + assert len(evolve_modes) == 1 + assert evolve_modes[0]["name"] == "Evolve Lite" + assert evolve_modes[0]["metadata"] == {"accent": "teal"} + assert evolve_modes[0]["shortcuts"] == ["recall-first"] + def test_preserves_existing_roomodes_yaml(self, temp_project_dir, install_runner, roo_fixtures, file_assertions): """Install evolve when user has existing .roomodes (YAML) - it must be preserved.""" # Setup: Create user's .roomodes in YAML format @@ -234,12 +263,70 @@ def test_preserves_all_roo_content_together(self, temp_project_dir, install_runn assert len(evolve_modes) == 1, f"Expected 1 evolve-lite mode, found {len(evolve_modes)}" +@pytest.mark.platform_integrations +class TestCodexPreservation: + """Test that Codex installation preserves existing user data.""" + + def test_preserves_existing_marketplace_entries(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Install evolve when user already has marketplace entries - they must be preserved.""" + codex_fixtures.create_existing_plugin(temp_project_dir) + marketplace_file = codex_fixtures.create_existing_marketplace(temp_project_dir) + original_data = json.loads(marketplace_file.read_text()) + + install_runner.run("install", platform="codex") + + file_assertions.assert_valid_json(marketplace_file) + current_data = json.loads(marketplace_file.read_text()) + + custom_plugins = [entry for entry in current_data["plugins"] if entry["name"] == "my-codex-plugin"] + assert len(custom_plugins) == 1, "User's existing plugin entry was removed or duplicated!" + assert custom_plugins[0] == original_data["plugins"][0] + + evolve_plugins = [entry for entry in current_data["plugins"] if entry["name"] == "evolve-lite"] + assert len(evolve_plugins) == 1, "Evolve plugin entry missing from marketplace.json" + + def test_preserves_existing_hooks_and_plugin_files(self, temp_project_dir, install_runner, codex_fixtures, file_assertions): + """Install evolve when user already has hooks and plugins - they must be preserved.""" + custom_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + plugin_json = custom_plugin / ".codex-plugin" / "plugin.json" + original_plugin_content = plugin_json.read_text() + hooks_file = codex_fixtures.create_existing_hooks(temp_project_dir) + + install_runner.run("install", platform="codex") + + file_assertions.assert_file_unchanged(plugin_json, original_plugin_content) + + current_hooks = json.loads(hooks_file.read_text()) + session_start_hooks = current_hooks["hooks"]["SessionStart"] + assert len(session_start_hooks) == 1, "User's SessionStart hook was removed!" + + prompt_hooks = current_hooks["hooks"]["UserPromptSubmit"] + custom_prompt_hooks = [ + hook + for group in prompt_hooks + for hook in group.get("hooks", []) + if hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + ] + assert len(custom_prompt_hooks) == 1, "User's UserPromptSubmit hook was removed!" + + evolve_hooks = [ + group + for group in prompt_hooks + if any( + "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in hook.get("command", "") + for hook in group.get("hooks", []) + ) + ] + assert len(evolve_hooks) == 1, "Evolve UserPromptSubmit hook was not added!" + assert evolve_hooks[0].get("matcher") == "" + + @pytest.mark.platform_integrations class TestMultiPlatformPreservation: """Test that installing multiple platforms preserves all user data.""" def test_install_all_platforms_preserves_everything( - self, temp_project_dir, install_runner, bob_fixtures, roo_fixtures, file_assertions + self, temp_project_dir, install_runner, bob_fixtures, roo_fixtures, codex_fixtures, file_assertions ): """Install all platforms when user has content everywhere - all must be preserved.""" # Setup: Create user content for both platforms @@ -249,11 +336,16 @@ def test_install_all_platforms_preserves_everything( roo_skill = roo_fixtures.create_existing_skill(temp_project_dir) roo_modes = roo_fixtures.create_existing_roomodes_json(temp_project_dir) + codex_plugin = codex_fixtures.create_existing_plugin(temp_project_dir) + codex_marketplace = codex_fixtures.create_existing_marketplace(temp_project_dir) + codex_hooks = codex_fixtures.create_existing_hooks(temp_project_dir) # Save original content bob_skill_content = (bob_skill / "SKILL.md").read_text() bob_command_content = bob_command.read_text() roo_skill_content = (roo_skill / "SKILL.md").read_text() + codex_plugin_content = (codex_plugin / ".codex-plugin" / "plugin.json").read_text() + codex_marketplace_data = json.loads(codex_marketplace.read_text()) # Action: Install all platforms install_runner.run("install", platform="all") @@ -268,6 +360,19 @@ def test_install_all_platforms_preserves_everything( roo_data = json.loads(roo_modes.read_text()) assert any(m["slug"] == "my-roo-mode" for m in roo_data["customModes"]) - # Assert: Evolve content is added to both + # Assert: ALL Codex content is preserved + file_assertions.assert_file_unchanged(codex_plugin / ".codex-plugin" / "plugin.json", codex_plugin_content) + current_marketplace = json.loads(codex_marketplace.read_text()) + assert any(entry["name"] == "my-codex-plugin" for entry in current_marketplace["plugins"]) + assert codex_marketplace_data["plugins"][0] in current_marketplace["plugins"] + current_hooks = json.loads(codex_hooks.read_text()) + assert any( + hook.get("command") == "python3 ~/.codex/hooks/custom_prompt_memory.py" + for group in current_hooks["hooks"]["UserPromptSubmit"] + for hook in group.get("hooks", []) + ) + + # Assert: Evolve content is added everywhere file_assertions.assert_dir_exists(temp_project_dir / ".bob" / "skills" / "evolve-learn") file_assertions.assert_dir_exists(temp_project_dir / ".roo" / "skills" / "evolve-learn") + file_assertions.assert_dir_exists(temp_project_dir / "plugins" / "evolve-lite")