Skip to content

Commit aa93ad3

Browse files
illeatmyhatclaude
andauthored
fix(platform-integrations): make bob custom-mode merge robust (#263)
The Bob custom_modes.yaml merge had two faults that combined to silently drop the evolve-lite mode while still reporting success: - Sentinel detection used a substring check (`if start in existing`). The install-evolve-lite marketplace mode documents the literal `# >>>evolve:evolve-lite<<<` in its customInstructions, so the merge thought a block already existed, took the replace branch, found no matching end sentinel, and no-op'd. Detection is now a line-anchored start..end regex. - The inserted block hardcoded 2-space list indentation. A target written with 0-indent sequence items (yaml.safe_dump / marketplace tooling) ended up with mixed indentation = invalid YAML. The block now matches the indentation already used under customModes:. remove_yaml_custom_mode is likewise line-anchored. Adds a regression test reproducing the exact failure (0-indent target + sentinel literal in another mode). Co-authored-by: Punleuk Oum <5661986+illeatmyhat@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent cbcf6b1 commit aa93ad3

2 files changed

Lines changed: 74 additions & 6 deletions

File tree

platform-integrations/install.sh

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,6 @@ class FileOps:
342342
mode_block = "\n".join(mode_lines).strip()
343343
start = _sentinel_start(slug)
344344
end = _sentinel_end(slug)
345-
block = f"\n{start}\n {mode_block.replace(chr(10), chr(10) + ' ')}\n{end}\n"
346345
347346
try:
348347
with open(target_yaml_path) as f:
@@ -353,9 +352,34 @@ class FileOps:
353352
if not existing.strip() or "customModes:" not in existing:
354353
existing = "customModes:\n"
355354
356-
if start in existing:
357-
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
358-
new_content = pattern.sub(block.strip(), existing)
355+
# Match the list-item indentation already used under `customModes:` so the
356+
# inserted block doesn't mix 0-indent and 2-indent sequence items (which is
357+
# invalid YAML). The source uses 2-space items; a target written by
358+
# yaml.safe_dump (Bob/marketplace tooling) may use 0-space. Detect and match.
359+
item_indent = " "
360+
seen_modes = False
361+
for ln in existing.splitlines():
362+
if ln.strip() == "customModes:":
363+
seen_modes = True
364+
continue
365+
if seen_modes and ln.lstrip().startswith("- "):
366+
item_indent = ln[: len(ln) - len(ln.lstrip())]
367+
break
368+
block_body = "\n".join(item_indent + ln if ln else ln for ln in mode_block.split("\n"))
369+
block = f"\n{start}\n{block_body}\n{end}\n"
370+
371+
# Match a *real* sentinel block only: the start and end markers must each
372+
# sit at the beginning of a line. A bare sentinel substring inside another
373+
# mode's quoted scalar (e.g. the install-evolve-lite mode documents the
374+
# literal `# >>>evolve:evolve-lite<<<` in its customInstructions) must NOT
375+
# be treated as an existing block — otherwise the replace finds no matching
376+
# end, no-ops, and the merge is silently dropped while still reporting ✓.
377+
block_re = re.compile(
378+
r"^[ \t]*" + re.escape(start) + r".*?^[ \t]*" + re.escape(end) + r"[^\n]*$",
379+
re.DOTALL | re.MULTILINE,
380+
)
381+
if block_re.search(existing):
382+
new_content = block_re.sub(lambda _m: block.strip(), existing)
359383
else:
360384
new_content = existing.rstrip() + block
361385
@@ -370,9 +394,11 @@ class FileOps:
370394
text = f.read()
371395
start = _sentinel_start(slug)
372396
end = _sentinel_end(slug)
397+
# Line-anchored so a sentinel literal mentioned inside another mode's
398+
# quoted text is never mistaken for a real block (see merge above).
373399
pattern = re.compile(
374-
r"\n?" + re.escape(start) + r".*?" + re.escape(end) + r"\n?",
375-
re.DOTALL
400+
r"^[ \t]*" + re.escape(start) + r".*?" + re.escape(end) + r"[^\n]*$\n?",
401+
re.DOTALL | re.MULTILINE,
376402
)
377403
self.atomic_write_text(target_yaml_path, pattern.sub("", text))
378404

tests/platform_integrations/test_idempotency.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import json
6+
import re
67

78
import pytest
89

@@ -140,6 +141,47 @@ def test_uninstall_removes_namespaced_shared_lib(self, temp_project_dir, install
140141

141142
file_assertions.assert_dir_not_exists(bob_dir / "lib" / "evolve-lite")
142143

144+
def test_install_merges_mode_despite_sentinel_literal_in_another_mode(self, temp_project_dir, install_runner, file_assertions):
145+
"""A sentinel literal quoted inside another mode's text must not block the merge.
146+
147+
Regression: the install-evolve-lite marketplace mode documents the literal
148+
`# >>>evolve:evolve-lite<<<` in its customInstructions. A naive `if start in
149+
existing` substring check treated that as an existing block, took the replace
150+
branch, found no matching end sentinel, and silently dropped the merge while
151+
still reporting success. The sentinel match must be line-anchored.
152+
"""
153+
bob_dir = temp_project_dir / ".bob"
154+
modes_file = bob_dir / "custom_modes.yaml"
155+
modes_file.parent.mkdir(parents=True, exist_ok=True)
156+
# Reproduce the exact user failure: a 0-indent list (as yaml.safe_dump /
157+
# Bob marketplace tooling writes it) whose quoted text mentions the
158+
# sentinel literal. This trips BOTH the substring false-match and the
159+
# 0-indent-vs-2-indent mismatch.
160+
modes_file.write_text(
161+
"customModes:\n"
162+
"- slug: install-evolve-lite\n"
163+
" name: Install Evolve Lite\n"
164+
' customInstructions: "Merged between # >>>evolve:evolve-lite<<< sentinel comments."\n'
165+
" groups:\n"
166+
" - read\n"
167+
)
168+
169+
install_runner.run("install", platform="bob", mode="lite")
170+
171+
content = modes_file.read_text()
172+
# The evolve-lite mode was actually merged in (real sentinel block written).
173+
assert "# >>>evolve:evolve-lite<<<" in content
174+
175+
# All top-level list items share one indentation — a 0-indent/2-indent mix
176+
# would be invalid YAML (the indentation-matching fix).
177+
indents = set(re.findall(r"(?m)^([ \t]*)- slug:", content))
178+
assert len(indents) == 1, f"mixed custom-mode list indentation: {indents}"
179+
180+
slugs = re.findall(r"(?m)^[ \t]*- slug:\s*(\S+)", content)
181+
assert "evolve-lite" in slugs, f"evolve-lite mode not merged; slugs={slugs}"
182+
# ...and the pre-existing mode is preserved.
183+
assert "install-evolve-lite" in slugs
184+
143185
def test_install_preserves_user_content_during_legacy_purge(self, temp_project_dir, install_runner, bob_fixtures, file_assertions):
144186
"""The legacy purge MUST NOT clobber non-evolve user skills/commands."""
145187
bob_dir = temp_project_dir / ".bob"

0 commit comments

Comments
 (0)