fix(compress): UTF-8 stdout, empty/identical guards, inline-code validation, frontmatter cleanup

Five user-contributed fixes consolidated:

* UTF-8 stdout (#289 by @ousamabenyounes) — reconfigure stdout/stderr at the
  top of cli.py so Windows cp1252 consoles don't crash on the ❌ glyph in
  error/validation branches and mask the real error.
* Empty / unchanged compression guards (#292 by @ousamabenyounes, closes #237)
  — refuse empty input, refuse empty/whitespace/identical Claude output,
  read back the backup before touching the input. Five regression tests in
  tests/test_compress_safety.py.
* Inline backtick validation (#309 by @hireblackout) — validate_inline_codes
  closes the silent-overwrite gap where `npm install` → `yarn install`
  passed validation. Wired into validate(); 11 unit tests in
  tests/test_validate_inline.py.
* Frontmatter angle-bracket fix (#268 by @Bortlesboat, closes #266) —
  caveman-compress/SKILL.md description now uses FILEPATH instead of
  <filepath>, plus verify_repo gains a new
  verify_skill_frontmatter_upload_compatibility check, UTF-8 hardening for
  Windows, and the activation-banner regex no longer requires a trailing
  period.
* Two test fixtures (claude-md-project.md, mixed-with-code.md) updated so
  the new inline-backtick validator passes — they were silently dropping
  `server/src/`, `type(scope): description`, and `status` references in
  compression. The fixture is documentation of "good" compression, so the
  fix is to preserve those references.

Co-Authored-By: Ben Younes <ousama.benyounes@oratelecom.net>
Co-Authored-By: hireblackout <hireblackout@users.noreply.github.com>
Co-Authored-By: Andrew Barnes <Bortlesboat@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-05-01 01:03:59 +02:00
co-authored by Ben Younes hireblackout Andrew Barnes Claude Opus 4.7
parent 84cc3c14fa
commit 57a9b489a1
15 changed files with 478 additions and 37 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ description: >
Compress natural language memory files (CLAUDE.md, todos, preferences) into caveman format
to save input tokens. Preserves all technical substance, code, URLs, and structure.
Compressed version overwrites the original file. Human-readable backup saved as FILE.original.md.
Trigger: /caveman:compress <filepath> or "compress memory file"
Trigger: /caveman:compress FILEPATH or "compress memory file"
---
# Caveman Compress
+12
View File
@@ -7,6 +7,18 @@ Usage:
"""
import sys
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
# masking the real error and leaving the user with a half-compressed file.
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if callable(reconfigure):
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from pathlib import Path
from .compress import compress_file
+28 -1
View File
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
original_text = filepath.read_text(errors="ignore")
backup_path = filepath.with_name(filepath.stem + ".original.md")
if not original_text.strip():
print("❌ Refusing to compress: file is empty or whitespace-only.")
return False
# Check if backup already exists to prevent accidental overwriting
if backup_path.exists():
print(f"⚠️ Backup file already exists: {backup_path}")
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
print("Compressing with Claude...")
compressed = call_claude(build_compress_prompt(original_text))
# Save original as backup, write compressed to original path
if compressed is None or not compressed.strip():
print("❌ Compression aborted: Claude returned an empty response.")
print(" Original file is untouched (no backup created).")
return False
if compressed.strip() == original_text.strip():
print("❌ Compression aborted: output is identical to input.")
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
print(" already in caveman form. Original file is untouched (no backup created).")
return False
# Save original as backup, then verify the backup readback before
# touching the input file. If the filesystem dropped bytes (encoding,
# antivirus, disk full), unlink the bad backup and abort instead of
# leaving the user with a corrupt backup + compressed primary.
backup_path.write_text(original_text)
backup_readback = backup_path.read_text(errors="ignore")
if backup_readback != original_text:
print(f"❌ Backup write verification failed: {backup_path}")
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
try:
backup_path.unlink()
except OSError:
pass
return False
filepath.write_text(compressed)
# Step 2: Validate + Retry
+24
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import re
from collections import Counter
from pathlib import Path
URL_REGEX = re.compile(r"https?://[^\s)]+")
@@ -93,6 +94,12 @@ def count_bullets(text):
return len(BULLET_REGEX.findall(text))
def extract_inline_codes(text):
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
return re.findall(r"`([^`]+)`", text_without_fences)
# ---------- Validators ----------
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
def validate_inline_codes(orig, comp, result):
c1 = Counter(extract_inline_codes(orig))
c2 = Counter(extract_inline_codes(comp))
if c1 != c2:
lost = set(c1.keys()) - set(c2.keys())
added = set(c2.keys()) - set(c1.keys())
for code, count in c1.items():
if code in c2 and c2[code] < count:
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
if lost:
result.add_error(f"Inline code lost: {lost}")
if added:
result.add_warning(f"Inline code added: {added}")
# ---------- Main ----------
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
validate_urls(orig, comp, result)
validate_paths(orig, comp, result)
validate_bullets(orig, comp, result)
validate_inline_codes(orig, comp, result)
return result
@@ -7,6 +7,18 @@ Usage:
"""
import sys
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
# masking the real error and leaving the user with a half-compressed file.
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if callable(reconfigure):
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from pathlib import Path
from .compress import compress_file
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
original_text = filepath.read_text(errors="ignore")
backup_path = filepath.with_name(filepath.stem + ".original.md")
if not original_text.strip():
print("❌ Refusing to compress: file is empty or whitespace-only.")
return False
# Check if backup already exists to prevent accidental overwriting
if backup_path.exists():
print(f"⚠️ Backup file already exists: {backup_path}")
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
print("Compressing with Claude...")
compressed = call_claude(build_compress_prompt(original_text))
# Save original as backup, write compressed to original path
if compressed is None or not compressed.strip():
print("❌ Compression aborted: Claude returned an empty response.")
print(" Original file is untouched (no backup created).")
return False
if compressed.strip() == original_text.strip():
print("❌ Compression aborted: output is identical to input.")
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
print(" already in caveman form. Original file is untouched (no backup created).")
return False
# Save original as backup, then verify the backup readback before
# touching the input file. If the filesystem dropped bytes (encoding,
# antivirus, disk full), unlink the bad backup and abort instead of
# leaving the user with a corrupt backup + compressed primary.
backup_path.write_text(original_text)
backup_readback = backup_path.read_text(errors="ignore")
if backup_readback != original_text:
print(f"❌ Backup write verification failed: {backup_path}")
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
try:
backup_path.unlink()
except OSError:
pass
return False
filepath.write_text(compressed)
# Step 2: Validate + Retry
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import re
from collections import Counter
from pathlib import Path
URL_REGEX = re.compile(r"https?://[^\s)]+")
@@ -93,6 +94,12 @@ def count_bullets(text):
return len(BULLET_REGEX.findall(text))
def extract_inline_codes(text):
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
return re.findall(r"`([^`]+)`", text_without_fences)
# ---------- Validators ----------
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
def validate_inline_codes(orig, comp, result):
c1 = Counter(extract_inline_codes(orig))
c2 = Counter(extract_inline_codes(comp))
if c1 != c2:
lost = set(c1.keys()) - set(c2.keys())
added = set(c2.keys()) - set(c1.keys())
for code, count in c1.items():
if code in c2 and c2[code] < count:
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
if lost:
result.add_error(f"Inline code lost: {lost}")
if added:
result.add_warning(f"Inline code added: {added}")
# ---------- Main ----------
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
validate_urls(orig, comp, result)
validate_paths(orig, comp, result)
validate_bullets(orig, comp, result)
validate_inline_codes(orig, comp, result)
return result
+12
View File
@@ -7,6 +7,18 @@ Usage:
"""
import sys
# Force UTF-8 on stdout/stderr before any code can print. Windows consoles
# default to cp1252 and crash on the ❌ glyphs in error/validation branches,
# masking the real error and leaving the user with a half-compressed file.
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if callable(reconfigure):
try:
reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from pathlib import Path
from .compress import compress_file
+28 -1
View File
@@ -182,6 +182,10 @@ def compress_file(filepath: Path) -> bool:
original_text = filepath.read_text(errors="ignore")
backup_path = filepath.with_name(filepath.stem + ".original.md")
if not original_text.strip():
print("❌ Refusing to compress: file is empty or whitespace-only.")
return False
# Check if backup already exists to prevent accidental overwriting
if backup_path.exists():
print(f"⚠️ Backup file already exists: {backup_path}")
@@ -193,8 +197,31 @@ def compress_file(filepath: Path) -> bool:
print("Compressing with Claude...")
compressed = call_claude(build_compress_prompt(original_text))
# Save original as backup, write compressed to original path
if compressed is None or not compressed.strip():
print("❌ Compression aborted: Claude returned an empty response.")
print(" Original file is untouched (no backup created).")
return False
if compressed.strip() == original_text.strip():
print("❌ Compression aborted: output is identical to input.")
print(" Likely causes: Claude refused, returned the prompt verbatim, or the file is")
print(" already in caveman form. Original file is untouched (no backup created).")
return False
# Save original as backup, then verify the backup readback before
# touching the input file. If the filesystem dropped bytes (encoding,
# antivirus, disk full), unlink the bad backup and abort instead of
# leaving the user with a corrupt backup + compressed primary.
backup_path.write_text(original_text)
backup_readback = backup_path.read_text(errors="ignore")
if backup_readback != original_text:
print(f"❌ Backup write verification failed: {backup_path}")
print(" In-memory original differs from on-disk backup. Aborting before touching the input file.")
try:
backup_path.unlink()
except OSError:
pass
return False
filepath.write_text(compressed)
# Step 2: Validate + Retry
+24
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
import re
from collections import Counter
from pathlib import Path
URL_REGEX = re.compile(r"https?://[^\s)]+")
@@ -93,6 +94,12 @@ def count_bullets(text):
return len(BULLET_REGEX.findall(text))
def extract_inline_codes(text):
text_without_fences = re.sub(r"^```[\s\S]*?^```", "", text, flags=re.MULTILINE)
text_without_fences = re.sub(r"^~~~[\s\S]*?^~~~", "", text_without_fences, flags=re.MULTILINE)
return re.findall(r"`([^`]+)`", text_without_fences)
# ---------- Validators ----------
@@ -144,6 +151,22 @@ def validate_bullets(orig, comp, result):
result.add_warning(f"Bullet count changed too much: {b1} -> {b2}")
def validate_inline_codes(orig, comp, result):
c1 = Counter(extract_inline_codes(orig))
c2 = Counter(extract_inline_codes(comp))
if c1 != c2:
lost = set(c1.keys()) - set(c2.keys())
added = set(c2.keys()) - set(c1.keys())
for code, count in c1.items():
if code in c2 and c2[code] < count:
lost.add(f"{code} (lost {count - c2[code]} of {count} occurrences)")
if lost:
result.add_error(f"Inline code lost: {lost}")
if added:
result.add_warning(f"Inline code added: {added}")
# ---------- Main ----------
@@ -158,6 +181,7 @@ def validate(original_path: Path, compressed_path: Path) -> ValidationResult:
validate_urls(orig, comp, result)
validate_paths(orig, comp, result)
validate_bullets(orig, comp, result)
validate_inline_codes(orig, comp, result)
return result
+2 -2
View File
@@ -32,7 +32,7 @@ Pattern: controller-service-repository.
* services contain business logic
* repositories manage DB access
Structure:
Structure under `server/src/`:
* `server/src/controllers/` — route handlers + validation
* `server/src/services/` — business logic
@@ -95,7 +95,7 @@ Short-lived feature branches → PR → merge to `main`.
Branch format: `<type>/<ticket-id>-<short-description>`
Example: `feat/TF-123-add-slack-integration`
Commits: Conventional Commits
Commits: Conventional Commits — `type(scope): description`
Types: feat, fix, refactor, test, docs, chore, perf
Rules:
+1 -1
View File
@@ -69,7 +69,7 @@ const createTask = async (payload: CreateTaskPayload, token: string) => {
};
```
Response includes: `id`, `createdAt`, `status="todo"`.
Response includes: `id`, `createdAt`, `status` (default `"todo"`).
## Error Handling
+85
View File
@@ -0,0 +1,85 @@
"""Tests for the data-loss guards in `compress_file` (issue #237).
The compress orchestrator used to overwrite the input even when Claude
returned an empty string or a no-op echo, and used to write a backup
without verifying that the bytes survived the round-trip. These tests
pin the new defensive checks: nothing on disk changes when the compressed
output is empty or identical to the input, and a backup-write that drops
bytes is detected before the input is overwritten.
"""
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from skills.compress.scripts import compress as compress_mod # noqa: E402
class CompressSafetyTests(unittest.TestCase):
def _file_with(self, dirpath: Path, text: str) -> Path:
path = dirpath / "task.md"
path.write_text(text)
return path
def test_empty_input_refused(self):
with tempfile.TemporaryDirectory() as tmp:
path = self._file_with(Path(tmp), "")
with mock.patch.object(compress_mod, "call_claude") as call:
ok = compress_mod.compress_file(path)
self.assertFalse(ok)
call.assert_not_called()
self.assertEqual(path.read_text(), "")
self.assertFalse((Path(tmp) / "task.original.md").exists())
def test_empty_compressed_output_does_not_touch_disk(self):
with tempfile.TemporaryDirectory() as tmp:
original = "# Heading\n\nSome long natural language paragraph that should be compressed.\n"
path = self._file_with(Path(tmp), original)
with mock.patch.object(compress_mod, "call_claude", return_value=""):
ok = compress_mod.compress_file(path)
self.assertFalse(ok)
self.assertEqual(path.read_text(), original)
self.assertFalse((Path(tmp) / "task.original.md").exists())
def test_whitespace_only_compressed_output_does_not_touch_disk(self):
with tempfile.TemporaryDirectory() as tmp:
original = "# Heading\n\nProse that should change.\n"
path = self._file_with(Path(tmp), original)
with mock.patch.object(compress_mod, "call_claude", return_value=" \n "):
ok = compress_mod.compress_file(path)
self.assertFalse(ok)
self.assertEqual(path.read_text(), original)
self.assertFalse((Path(tmp) / "task.original.md").exists())
def test_identical_compressed_output_does_not_touch_disk(self):
with tempfile.TemporaryDirectory() as tmp:
original = "# Heading\n\nProse.\n"
path = self._file_with(Path(tmp), original)
with mock.patch.object(compress_mod, "call_claude", return_value=original):
ok = compress_mod.compress_file(path)
self.assertFalse(ok)
self.assertEqual(path.read_text(), original)
self.assertFalse((Path(tmp) / "task.original.md").exists())
def test_real_compression_writes_backup_and_target(self):
with tempfile.TemporaryDirectory() as tmp:
original = "# Heading\n\nThe quick brown fox jumps over the lazy dog.\n"
compressed = "# Heading\n\nFox jump dog.\n"
path = self._file_with(Path(tmp), original)
with mock.patch.object(compress_mod, "call_claude", return_value=compressed), \
mock.patch.object(compress_mod, "validate") as v:
v.return_value = mock.Mock(is_valid=True, errors=[], warnings=[])
ok = compress_mod.compress_file(path)
self.assertTrue(ok)
self.assertEqual(path.read_text(), compressed)
backup = Path(tmp) / "task.original.md"
self.assertEqual(backup.read_text(), original)
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
from skills.compress.scripts.validate import ( # noqa: E402
ValidationResult,
extract_inline_codes,
validate,
validate_inline_codes,
)
class TestExtractInlineCodes(unittest.TestCase):
def test_fenced_blocks_excluded(self):
text = "```\ncode here\n```\n`inline code`"
result = extract_inline_codes(text)
self.assertEqual(result, ["inline code"])
def test_inline_only(self):
text = "Use `rm -rf /` to delete everything"
result = extract_inline_codes(text)
self.assertEqual(result, ["rm -rf /"])
def test_mixed_content(self):
text = """
Some text with `inline1` and `inline2`.
```
code block
```
More text with `inline3`.
"""
result = extract_inline_codes(text)
self.assertEqual(set(result), {"inline1", "inline2", "inline3"})
def test_empty(self):
self.assertEqual(extract_inline_codes("no backticks here"), [])
class TestValidateInlineCodes(unittest.TestCase):
def test_match(self):
result = ValidationResult()
validate_inline_codes("use `cmd` here", "use `cmd` here", result)
self.assertTrue(result.is_valid)
def test_lost(self):
result = ValidationResult()
validate_inline_codes("use `cmd` here", "use here", result)
self.assertFalse(result.is_valid)
self.assertIn("Inline code lost", result.errors[0])
def test_added(self):
result = ValidationResult()
validate_inline_codes("use here", "use `new` here", result)
self.assertTrue(result.is_valid)
self.assertIn("Inline code added", result.warnings[0])
def test_empty_orig(self):
result = ValidationResult()
validate_inline_codes("no codes", "use `new` here", result)
self.assertTrue(result.is_valid)
def test_both_empty(self):
result = ValidationResult()
validate_inline_codes("plain text", "also plain", result)
self.assertTrue(result.is_valid)
class TestValidateIntegration(unittest.TestCase):
def test_validate_inline_codes_wired(self):
with tempfile.TemporaryDirectory() as tmp:
orig = Path(tmp) / "original.md"
comp = Path(tmp) / "compressed.md"
orig.write_text("Run `rm -rf /` to delete")
comp.write_text("Run to delete")
result = validate(orig, comp)
self.assertFalse(result.is_valid)
self.assertTrue(any("Inline code lost" in e for e in result.errors))
if __name__ == "__main__":
unittest.main()
+110 -30
View File
@@ -37,6 +37,8 @@ def run(
check: bool = True,
) -> subprocess.CompletedProcess[str]:
merged_env = os.environ.copy()
# Keep Python subprocess output decodable on Windows when the CLI prints Unicode.
merged_env.setdefault("PYTHONIOENCODING", "utf-8")
if env:
merged_env.update(env)
result = subprocess.run(
@@ -44,6 +46,7 @@ def run(
cwd=cwd,
env=merged_env,
text=True,
encoding="utf-8",
capture_output=True,
check=False,
)
@@ -57,7 +60,65 @@ def run(
def read_json(path: Path) -> object:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))
def shell_path(path: Path) -> str:
return str(path).replace("\\", "/") if os.name == "nt" else str(path)
def _frontmatter_description(path: Path) -> str:
lines = path.read_text(encoding="utf-8").splitlines()
ensure(lines and lines[0] == "---", f"{path} missing YAML frontmatter")
description_lines: list[str] = []
collecting = False
block_indent: int | None = None
for line in lines[1:]:
if line == "---":
break
if collecting:
stripped = line.strip()
if not stripped:
description_lines.append("")
continue
indent = len(line) - len(line.lstrip(" \t"))
if block_indent is None:
if indent == 0:
break
block_indent = indent
elif indent < block_indent:
break
description_lines.append(stripped)
continue
if line.startswith("description:"):
value = line.split(":", 1)[1].strip()
# Folded (>) and literal (|) block scalars, with optional chomping (-/+).
if value and value[0] in ("|", ">"):
collecting = True
continue
return value.strip("'\"")
return " ".join(part for part in description_lines if part)
def verify_skill_frontmatter_upload_compatibility() -> None:
section("Skill Frontmatter Upload Compatibility")
skill_paths = [
ROOT / "skills/caveman/SKILL.md",
ROOT / "skills/caveman-commit/SKILL.md",
ROOT / "skills/caveman-help/SKILL.md",
ROOT / "skills/caveman-review/SKILL.md",
ROOT / "caveman-compress/SKILL.md",
]
for path in skill_paths:
description = _frontmatter_description(path)
ensure(
"<" not in description and ">" not in description,
f"{path} description contains XML-like angle brackets",
)
print("Skill frontmatter descriptions avoid XML-like tags")
def verify_synced_files() -> None:
@@ -72,19 +133,26 @@ def verify_synced_files() -> None:
ROOT / ".windsurf/skills/caveman/SKILL.md",
]
for copy in skill_copies:
ensure(copy.read_text() == skill_source.read_text(), f"Skill copy mismatch: {copy}")
ensure(
copy.read_text(encoding="utf-8") == skill_source.read_text(encoding="utf-8"),
f"Skill copy mismatch: {copy}",
)
rule_copies = [
ROOT / ".clinerules/caveman.md",
ROOT / ".github/copilot-instructions.md",
]
for copy in rule_copies:
ensure(copy.read_text() == rule_source.read_text(), f"Rule copy mismatch: {copy}")
ensure(
copy.read_text(encoding="utf-8") == rule_source.read_text(encoding="utf-8"),
f"Rule copy mismatch: {copy}",
)
with zipfile.ZipFile(ROOT / "caveman.skill") as archive:
ensure("caveman/SKILL.md" in archive.namelist(), "caveman.skill missing caveman/SKILL.md")
ensure(
archive.read("caveman/SKILL.md").decode("utf-8") == skill_source.read_text(),
archive.read("caveman/SKILL.md").decode("utf-8")
== skill_source.read_text(encoding="utf-8"),
"caveman.skill payload mismatch",
)
@@ -113,8 +181,8 @@ def verify_manifests_and_syntax() -> None:
run(["bash", "-n", "hooks/caveman-statusline.sh"])
# Ensure install/uninstall scripts include caveman-config.js
install_sh = (ROOT / "hooks/install.sh").read_text()
uninstall_sh = (ROOT / "hooks/uninstall.sh").read_text()
install_sh = (ROOT / "hooks/install.sh").read_text(encoding="utf-8")
uninstall_sh = (ROOT / "hooks/uninstall.sh").read_text(encoding="utf-8")
ensure("caveman-config.js" in install_sh, "install.sh missing caveman-config.js")
ensure("caveman-config.js" in uninstall_sh, "uninstall.sh missing caveman-config.js")
@@ -123,9 +191,9 @@ def verify_manifests_and_syntax() -> None:
def verify_powershell_static() -> None:
section("PowerShell Static Checks")
install_text = (ROOT / "hooks/install.ps1").read_text()
uninstall_text = (ROOT / "hooks/uninstall.ps1").read_text()
statusline_text = (ROOT / "hooks/caveman-statusline.ps1").read_text()
install_text = (ROOT / "hooks/install.ps1").read_text(encoding="utf-8")
uninstall_text = (ROOT / "hooks/uninstall.ps1").read_text(encoding="utf-8")
statusline_text = (ROOT / "hooks/caveman-statusline.ps1").read_text(encoding="utf-8")
ensure("caveman-config.js" in install_text, "install.ps1 missing caveman-config.js")
ensure("caveman-config.js" in uninstall_text, "uninstall.ps1 missing caveman-config.js")
@@ -212,8 +280,9 @@ def verify_hook_install_flow() -> None:
"hooks": {"Notification": [{"hooks": [{"type": "command", "command": "echo keep-me"}]}]},
}
(claude_dir / "settings.json").write_text(json.dumps(existing_settings, indent=2) + "\n")
hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)}
run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
run(["bash", "hooks/install.sh"], env=hook_env)
settings = read_json(claude_dir / "settings.json")
hooks = settings["hooks"]
@@ -223,33 +292,37 @@ def verify_hook_install_flow() -> None:
activate = run(
["node", "hooks/caveman-activate.js"],
env={"HOME": str(home)},
env=hook_env,
)
ensure("CAVEMAN MODE ACTIVE." in activate.stdout, "activation output missing caveman banner")
ensure("CAVEMAN MODE ACTIVE" in activate.stdout, "activation output missing caveman banner")
ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "activation should stay quiet when custom statusline exists")
ensure((claude_dir / ".caveman-active").read_text() == "full", "activation flag should default to full")
ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "full", "activation flag should default to full")
# Test configurable default mode via CAVEMAN_DEFAULT_MODE env var
activate_custom = run(
["node", "hooks/caveman-activate.js"],
env={"HOME": str(home), "CAVEMAN_DEFAULT_MODE": "ultra"},
env={**hook_env, "CAVEMAN_DEFAULT_MODE": "ultra"},
)
ensure("CAVEMAN MODE ACTIVE" in activate_custom.stdout, "activation with custom default missing banner")
ensure(
(claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra",
"CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra",
)
ensure("CAVEMAN MODE ACTIVE." in activate_custom.stdout, "activation with custom default missing banner")
ensure((claude_dir / ".caveman-active").read_text() == "ultra", "CAVEMAN_DEFAULT_MODE=ultra should set flag to ultra")
# Test "off" mode — activation skipped, flag removed
activate_off = run(
["node", "hooks/caveman-activate.js"],
env={"HOME": str(home), "CAVEMAN_DEFAULT_MODE": "off"},
env={**hook_env, "CAVEMAN_DEFAULT_MODE": "off"},
)
ensure("CAVEMAN MODE ACTIVE." not in activate_off.stdout, "off mode should not emit caveman banner")
ensure("CAVEMAN MODE ACTIVE" not in activate_off.stdout, "off mode should not emit caveman banner")
ensure(not (claude_dir / ".caveman-active").exists(), "off mode should remove flag file")
# Test mode tracker with /caveman when default is off — should NOT write flag
subprocess.run(
["node", "hooks/caveman-mode-tracker.js"],
cwd=ROOT,
env={**os.environ, "HOME": str(home), "CAVEMAN_DEFAULT_MODE": "off"},
env={**os.environ, **hook_env, "CAVEMAN_DEFAULT_MODE": "off"},
text=True,
encoding="utf-8",
input='{"prompt":"/caveman"}',
capture_output=True,
check=True,
@@ -261,27 +334,32 @@ def verify_hook_install_flow() -> None:
run(
["node", "hooks/caveman-mode-tracker.js"],
env={"HOME": str(home)},
env=hook_env,
check=True,
)
ultra_prompt = subprocess.run(
["node", "hooks/caveman-mode-tracker.js"],
cwd=ROOT,
env={**os.environ, "HOME": str(home)},
env={**os.environ, **hook_env},
text=True,
encoding="utf-8",
input='{"prompt":"/caveman ultra"}',
capture_output=True,
check=True,
)
ensure(ultra_prompt.stdout == "", "mode tracker should stay silent")
ensure((claude_dir / ".caveman-active").read_text() == "ultra", "mode tracker did not record ultra")
ensure(
"CAVEMAN MODE ACTIVE (ultra)" in ultra_prompt.stdout,
"mode tracker should emit active-mode reinforcement",
)
ensure((claude_dir / ".caveman-active").read_text(encoding="utf-8") == "ultra", "mode tracker did not record ultra")
subprocess.run(
["node", "hooks/caveman-mode-tracker.js"],
cwd=ROOT,
env={**os.environ, "HOME": str(home)},
env={**os.environ, **hook_env},
text=True,
encoding="utf-8",
input='{"prompt":"normal mode"}',
capture_output=True,
check=True,
@@ -291,27 +369,28 @@ def verify_hook_install_flow() -> None:
(claude_dir / ".caveman-active").write_text("wenyan-ultra")
statusline = run(
["bash", "hooks/caveman-statusline.sh"],
env={"HOME": str(home)},
env=hook_env,
)
ensure("[CAVEMAN:WENYAN-ULTRA]" in statusline.stdout, "statusline badge output mismatch")
reinstall = run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
reinstall = run(["bash", "hooks/install.sh"], env=hook_env)
ensure("Nothing to do" in reinstall.stdout, "install.sh should be idempotent")
run(["bash", "hooks/uninstall.sh"], env={"HOME": str(home)})
run(["bash", "hooks/uninstall.sh"], env=hook_env)
settings_after = read_json(claude_dir / "settings.json")
ensure(settings_after == existing_settings, "uninstall.sh did not restore non-caveman settings")
ensure(not (claude_dir / ".caveman-active").exists(), "uninstall.sh should remove flag file")
with tempfile.TemporaryDirectory(prefix="caveman-verify-fresh-") as temp_root:
home = Path(temp_root) / "home"
run(["bash", "hooks/install.sh"], env={"HOME": str(home)})
claude_dir = home / ".claude"
hook_env = {"HOME": shell_path(home), "CLAUDE_CONFIG_DIR": shell_path(claude_dir)}
run(["bash", "hooks/install.sh"], env=hook_env)
settings = read_json(claude_dir / "settings.json")
ensure("statusLine" in settings, "fresh install should configure statusline")
activate = run(["node", "hooks/caveman-activate.js"], env={"HOME": str(home)})
activate = run(["node", "hooks/caveman-activate.js"], env=hook_env)
ensure("STATUSLINE SETUP NEEDED" not in activate.stdout, "fresh install should not nudge for statusline")
run(["bash", "hooks/uninstall.sh"], env={"HOME": str(home)})
run(["bash", "hooks/uninstall.sh"], env=hook_env)
ensure(read_json(claude_dir / "settings.json") == {}, "fresh uninstall should leave empty settings")
print("Claude hook install/uninstall flow OK")
@@ -319,6 +398,7 @@ def verify_hook_install_flow() -> None:
def main() -> int:
checks = [
verify_skill_frontmatter_upload_compatibility,
verify_synced_files,
verify_manifests_and_syntax,
verify_powershell_static,