aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/cj-remove-block.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:56:39 -0500
commitc1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d (patch)
tree3e6dcc682cbf2311409e7f71d83a7d4088392068 /claude-templates/.ai/scripts/cj-remove-block.py
parent2b471da4bab014a2e096f63edc7aac235fc40fdd (diff)
parent69c5e4ace81586c05dea6a9a3afd54dafa61a73b (diff)
downloadrulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.tar.gz
rulesets-c1d4e3c4a42abd01bc7ef83b1d6ae036ee32ef1d.zip
Merge commit '69c5e4ace81586c05dea6a9a3afd54dafa61a73b' as 'claude-templates'
Diffstat (limited to 'claude-templates/.ai/scripts/cj-remove-block.py')
-rw-r--r--claude-templates/.ai/scripts/cj-remove-block.py101
1 files changed, 101 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/cj-remove-block.py b/claude-templates/.ai/scripts/cj-remove-block.py
new file mode 100644
index 0000000..71c7b3d
--- /dev/null
+++ b/claude-templates/.ai/scripts/cj-remove-block.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""cj-remove-block — Remove a cj annotation block from an org file by line range.
+
+Idempotently deletes lines [start, end] (1-indexed, inclusive) from the file,
+but only after validating that those lines actually look like a cj annotation
+(either a `#+begin_src cj: ... #+end_src` fence pair or a single `cj:` line).
+The validation step is the point — it protects against accidentally trimming
+the wrong block when line numbers drift between a `cj-scan` call and a remove call.
+
+Usage:
+ cj-remove-block --file FILE.org --start N --end M
+
+Companion to the /respond-to-cj-comments skill and to cj-scan.py.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s")
+
+
+def looks_like_cj_range(lines: list[str], start: int, end: int) -> tuple[bool, str]:
+ """Return (ok, reason). Validates start..end (1-indexed, inclusive) is a cj range."""
+ if end < start:
+ return False, f"Range end ({end}) is before start ({start})"
+ if start < 1 or end > len(lines):
+ return False, (
+ f"Range {start}..{end} is out of bounds for a file with {len(lines)} lines"
+ )
+
+ first = lines[start - 1]
+ last = lines[end - 1]
+
+ if start == end:
+ # Single-line removal must look like legacy inline.
+ if LEGACY_CJ_RE.match(first):
+ return True, ""
+ return False, (
+ f"Line {start} does not look like a legacy inline cj: line "
+ f"(got: {first[:60]!r})"
+ )
+
+ # Multi-line removal must look like a source-block fence pair.
+ if not SRC_OPEN_RE.match(first):
+ return False, (
+ f"Line {start} does not look like a #+begin_src cj: opening fence "
+ f"(got: {first[:60]!r})"
+ )
+ if not SRC_CLOSE_RE.match(last):
+ return False, (
+ f"Line {end} does not look like a #+end_src closing fence "
+ f"(got: {last[:60]!r})"
+ )
+ return True, ""
+
+
+def remove_range(path: Path, start: int, end: int) -> None:
+ """Read path, validate range looks like cj content, remove the range, write back."""
+ text = path.read_text()
+ had_trailing_newline = text.endswith("\n")
+ lines = text.splitlines(keepends=False)
+
+ ok, reason = looks_like_cj_range(lines, start, end)
+ if not ok:
+ print(f"cj-remove-block: refusing to remove — {reason}", file=sys.stderr)
+ sys.exit(1)
+
+ new_lines = lines[: start - 1] + lines[end:]
+ new_text = "\n".join(new_lines)
+ if new_lines and had_trailing_newline:
+ new_text += "\n"
+ elif not new_lines and had_trailing_newline:
+ new_text = ""
+ path.write_text(new_text)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Remove a cj annotation block from an org file by line range.",
+ )
+ parser.add_argument("--file", required=True, type=Path, help="Path to the org file.")
+ parser.add_argument("--start", required=True, type=int, help="Start line (1-indexed, inclusive).")
+ parser.add_argument("--end", required=True, type=int, help="End line (1-indexed, inclusive).")
+ args = parser.parse_args()
+
+ if not args.file.is_file():
+ print(f"Not a file: {args.file}", file=sys.stderr)
+ return 2
+
+ remove_range(args.file, args.start, args.end)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())