Skip to content

Commit b5c7ce8

Browse files
committed
fix(platform-integrations): preserve merged evolve config entries
1 parent c112f58 commit b5c7ce8

5 files changed

Lines changed: 338 additions & 22 deletions

File tree

platform-integrations/INSTALL_SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,10 @@ All JSON writes use atomic read-modify-write:
174174
3. Write to `<path>.evolve.tmp`
175175
4. `os.replace(tmp, path)` — atomic on POSIX
176176

177-
**Key upsert** (`mcpServers.evolve`, `hooks.UserPromptSubmit` scaffolding): navigate nested keys via `dict.setdefault`, set leaf value.
177+
**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.
178178

179179
**Array upsert** (`.roomodes` `customModes`, `marketplace.json` `plugins`): iterate array, find item where the identity key matches,
180-
replace in-place; append if not found.
180+
merge matching dict items in place; append if not found.
181181

182182
**Array remove**: filter array by `item["slug"] != target_slug`, write back.
183183

@@ -214,7 +214,7 @@ All operations are safe to run multiple times:
214214
- JSON writes upsert (replace-if-exists, insert-if-not)
215215
- YAML writes check for sentinel before appending
216216
- Claude plugin install is idempotent by the Claude CLI itself
217-
- Codex marketplace and hook writes replace matching Evolve entries and preserve user-owned entries
217+
- Codex marketplace and hook writes merge matching Evolve entries and preserve user-owned entries
218218

219219
---
220220

platform-integrations/install.sh

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ resolve_source
125125

126126
exec python3 - "$SOURCE_DIR" "$@" <<'PYEOF'
127127
import argparse
128+
import copy
128129
import json
129130
import os
130131
import re
@@ -259,13 +260,25 @@ def remove_file(path):
259260
260261
# ── JSON config helpers ────────────────────────────────────────────────────────
261262
263+
def merge_json_value(existing, desired):
264+
"""Recursively merge JSON-like values, preserving unknown keys from existing objects."""
265+
if isinstance(existing, dict) and isinstance(desired, dict):
266+
merged = copy.deepcopy(existing)
267+
for key, desired_value in desired.items():
268+
merged[key] = merge_json_value(merged.get(key), desired_value)
269+
return merged
270+
return copy.deepcopy(desired)
271+
272+
262273
def upsert_json_key(path, key_path: list, value):
263274
"""Upsert a nested key into a JSON file. key_path = ['a', 'b', 'c'] → data['a']['b']['c'] = value."""
264275
data = read_json(path)
265276
cursor = data
266277
for key in key_path[:-1]:
267-
cursor = cursor.setdefault(key, {})
268-
cursor[key_path[-1]] = value
278+
if not isinstance(cursor.get(key), dict):
279+
cursor[key] = {}
280+
cursor = cursor[key]
281+
cursor[key_path[-1]] = merge_json_value(cursor.get(key_path[-1]), value)
269282
atomic_write_json(path, data)
270283
271284
@@ -289,10 +302,10 @@ def upsert_json_array_item(path, array_key: str, item: dict, id_key: str):
289302
arr = data.setdefault(array_key, [])
290303
for i, existing in enumerate(arr):
291304
if existing.get(id_key) == item.get(id_key):
292-
arr[i] = item
305+
arr[i] = merge_json_value(existing, item)
293306
break
294307
else:
295-
arr.append(item)
308+
arr.append(copy.deepcopy(item))
296309
atomic_write_json(path, data)
297310
298311
@@ -337,10 +350,10 @@ def upsert_codex_marketplace_entry(path, item):
337350
338351
for index, existing in enumerate(plugins):
339352
if isinstance(existing, dict) and existing.get("name") == item.get("name"):
340-
plugins[index] = item
353+
plugins[index] = merge_json_value(existing, item)
341354
break
342355
else:
343-
plugins.append(item)
356+
plugins.append(copy.deepcopy(item))
344357
345358
atomic_write_json(path, data)
346359
@@ -363,22 +376,82 @@ def _is_codex_recall_command(command):
363376
return isinstance(command, str) and "plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py" in command
364377
365378
379+
def _codex_recall_hook():
380+
return {
381+
"type": "command",
382+
"command": _codex_recall_hook_command(),
383+
"statusMessage": "Loading Evolve guidance",
384+
}
385+
386+
366387
def _codex_recall_hook_group():
367388
return {
368389
"matcher": "",
369-
"hooks": [
370-
{
371-
"type": "command",
372-
"command": _codex_recall_hook_command(),
373-
"statusMessage": "Loading Evolve guidance",
374-
}
375-
]
390+
"hooks": [_codex_recall_hook()],
376391
}
377392
378393
379-
def _group_contains_codex_recall_command(group):
394+
def _iter_group_hooks(group):
380395
hooks = group.get("hooks", [])
381-
return any(isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")) for hook in hooks)
396+
if isinstance(hooks, list):
397+
return hooks
398+
if isinstance(hooks, dict):
399+
return hooks.values()
400+
return []
401+
402+
403+
def _group_contains_codex_recall_command(group):
404+
return any(isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")) for hook in _iter_group_hooks(group))
405+
406+
407+
def _upsert_codex_recall_hook_into_group(group):
408+
updated_group = copy.deepcopy(group)
409+
recall_hook = _codex_recall_hook()
410+
hooks = updated_group.get("hooks")
411+
412+
if isinstance(hooks, list):
413+
for index, existing_hook in enumerate(hooks):
414+
if isinstance(existing_hook, dict) and _is_codex_recall_command(existing_hook.get("command")):
415+
hooks[index] = merge_json_value(existing_hook, recall_hook)
416+
break
417+
else:
418+
hooks.append(copy.deepcopy(recall_hook))
419+
return updated_group
420+
421+
if isinstance(hooks, dict):
422+
for key, existing_hook in hooks.items():
423+
if isinstance(existing_hook, dict) and _is_codex_recall_command(existing_hook.get("command")):
424+
hooks[key] = merge_json_value(existing_hook, recall_hook)
425+
break
426+
else:
427+
hooks["evolve-lite"] = copy.deepcopy(recall_hook)
428+
return updated_group
429+
430+
updated_group["hooks"] = [copy.deepcopy(recall_hook)]
431+
return updated_group
432+
433+
434+
def _remove_codex_recall_hook_from_group(group):
435+
updated_group = copy.deepcopy(group)
436+
hooks = updated_group.get("hooks")
437+
438+
if isinstance(hooks, list):
439+
updated_group["hooks"] = [
440+
hook
441+
for hook in hooks
442+
if not (isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")))
443+
]
444+
return updated_group
445+
446+
if isinstance(hooks, dict):
447+
updated_group["hooks"] = {
448+
key: hook
449+
for key, hook in hooks.items()
450+
if not (isinstance(hook, dict) and _is_codex_recall_command(hook.get("command")))
451+
}
452+
return updated_group
453+
454+
return updated_group
382455
383456
384457
def upsert_codex_user_prompt_hook(path, group):
@@ -401,10 +474,10 @@ def upsert_codex_user_prompt_hook(path, group):
401474
402475
for index, existing in enumerate(groups):
403476
if isinstance(existing, dict) and _group_contains_codex_recall_command(existing):
404-
groups[index] = group
477+
groups[index] = _upsert_codex_recall_hook_into_group(existing)
405478
break
406479
else:
407-
groups.append(group)
480+
groups.append(copy.deepcopy(group))
408481
409482
atomic_write_json(path, data)
410483
@@ -424,7 +497,10 @@ def remove_codex_user_prompt_hook(path):
424497
return
425498
426499
hooks["UserPromptSubmit"] = [
427-
group for group in groups if not (isinstance(group, dict) and _group_contains_codex_recall_command(group))
500+
_remove_codex_recall_hook_from_group(group)
501+
if isinstance(group, dict) and _group_contains_codex_recall_command(group)
502+
else group
503+
for group in groups
428504
]
429505
if not hooks["UserPromptSubmit"]:
430506
hooks.pop("UserPromptSubmit", None)

tests/platform_integrations/conftest.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,28 @@ def create_existing_mcp_config(project_dir: Path):
314314
mcp_file.write_text(json.dumps(data, indent=2) + "\n")
315315
return mcp_file
316316

317+
@staticmethod
318+
def create_existing_mcp_config_with_evolve(project_dir: Path):
319+
"""Create an mcp.json with a user-customized evolve server entry."""
320+
mcp_file = project_dir / ".bob" / "mcp.json"
321+
mcp_file.parent.mkdir(parents=True, exist_ok=True)
322+
323+
data = {
324+
"mcpServers": {
325+
"my-server": {"command": "node", "args": ["server.js"], "disabled": False},
326+
"evolve": {
327+
"command": "python3",
328+
"args": ["old_evolve.py"],
329+
"disabled": True,
330+
"env": {"EVOLVE_PROFILE": "local"},
331+
"metadata": {"managedBy": "user"},
332+
},
333+
}
334+
}
335+
336+
mcp_file.write_text(json.dumps(data, indent=2) + "\n")
337+
return mcp_file
338+
317339

318340
class RooFixtures:
319341
"""Helper class to create Roo platform test fixtures."""
@@ -352,6 +374,35 @@ def create_existing_roomodes_json(project_dir: Path):
352374
roomodes_file.write_text(json.dumps(data, indent=2) + "\n")
353375
return roomodes_file
354376

377+
@staticmethod
378+
def create_existing_roomodes_json_with_evolve(project_dir: Path):
379+
"""Create a JSON .roomodes file that already contains a customized evolve-lite mode."""
380+
roomodes_file = project_dir / ".roomodes"
381+
382+
data = {
383+
"customModes": [
384+
{
385+
"slug": "my-roo-mode",
386+
"name": "My Roo Mode",
387+
"roleDefinition": "This is my custom Roo mode.",
388+
"customInstructions": "Follow my Roo instructions.",
389+
"groups": ["read", "edit"],
390+
},
391+
{
392+
"slug": "evolve-lite",
393+
"name": "My Evolve Lite",
394+
"roleDefinition": "Old evolve role definition.",
395+
"customInstructions": "Old evolve instructions.",
396+
"groups": ["read"],
397+
"metadata": {"accent": "teal"},
398+
"shortcuts": ["recall-first"],
399+
},
400+
]
401+
}
402+
403+
roomodes_file.write_text(json.dumps(data, indent=2) + "\n")
404+
return roomodes_file
405+
355406
@staticmethod
356407
def create_existing_roomodes_yaml(project_dir: Path):
357408
"""Create a .roomodes file in YAML format with a custom mode."""
@@ -467,6 +518,96 @@ def create_existing_hooks(project_dir: Path):
467518
)
468519
return hooks_file
469520

521+
@staticmethod
522+
def create_existing_hooks_with_shared_evolve_group(project_dir: Path):
523+
"""Create a list-based UserPromptSubmit group containing both user hooks and the evolve hook."""
524+
hooks_file = project_dir / ".codex" / "hooks.json"
525+
hooks_file.parent.mkdir(parents=True, exist_ok=True)
526+
hooks_file.write_text(
527+
json.dumps(
528+
{
529+
"hooks": {
530+
"UserPromptSubmit": [
531+
{
532+
"matcher": "src/.*",
533+
"hooks": [
534+
{
535+
"type": "command",
536+
"command": "python3 ~/.codex/hooks/custom_prompt_memory.py",
537+
"statusMessage": "Loading custom memory",
538+
},
539+
{
540+
"type": "command",
541+
"command": (
542+
"sh -lc '"
543+
'd="$PWD"; '
544+
"while :; do "
545+
'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; '
546+
'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; '
547+
'[ "$d" = "/" ] && break; '
548+
'd="$(dirname "$d")"; '
549+
"done; "
550+
"exit 1'"
551+
),
552+
"statusMessage": "Old evolve guidance",
553+
"delayMs": 250,
554+
},
555+
],
556+
}
557+
]
558+
}
559+
},
560+
indent=2,
561+
)
562+
+ "\n"
563+
)
564+
return hooks_file
565+
566+
@staticmethod
567+
def create_existing_hooks_with_dict_evolve_group(project_dir: Path):
568+
"""Create a dict-based UserPromptSubmit group containing user hooks and the evolve hook."""
569+
hooks_file = project_dir / ".codex" / "hooks.json"
570+
hooks_file.parent.mkdir(parents=True, exist_ok=True)
571+
hooks_file.write_text(
572+
json.dumps(
573+
{
574+
"hooks": {
575+
"UserPromptSubmit": [
576+
{
577+
"matcher": "src/.*",
578+
"hooks": {
579+
"memory": {
580+
"type": "command",
581+
"command": "python3 ~/.codex/hooks/custom_prompt_memory.py",
582+
"statusMessage": "Loading custom memory",
583+
},
584+
"evolve-lite": {
585+
"type": "command",
586+
"command": (
587+
"sh -lc '"
588+
'd="$PWD"; '
589+
"while :; do "
590+
'candidate="$d/plugins/evolve-lite/skills/recall/scripts/retrieve_entities.py"; '
591+
'if [ -f "$candidate" ]; then exec python3 "$candidate"; fi; '
592+
'[ "$d" = "/" ] && break; '
593+
'd="$(dirname "$d")"; '
594+
"done; "
595+
"exit 1'"
596+
),
597+
"statusMessage": "Old evolve guidance",
598+
"delayMs": 250,
599+
},
600+
},
601+
}
602+
]
603+
}
604+
},
605+
indent=2,
606+
)
607+
+ "\n"
608+
)
609+
return hooks_file
610+
470611

471612
@pytest.fixture
472613
def bob_fixtures():

0 commit comments

Comments
 (0)