1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
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())
|