#!/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())