diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-15 16:16:18 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-15 16:16:18 -0500 |
| commit | ee721ee96f984ccd38233309f0dfe6362057e644 (patch) | |
| tree | f84a3b21ae846c82a2677a59f54947ee5b557174 /.ai/scripts/cj-remove-block.py | |
| parent | 421b17a15219c7061ee92c07451993965fad88ea (diff) | |
| download | rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.tar.gz rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.zip | |
chore(ai): sync scripts and workflows from claude-templates
- todo-cleanup.el: :no-sync: tag now inherits down the outline tree
- task-review.org: completion procedure scoped to top-level entries
- cj-scan.py + cj-remove-block.py: helpers for cj-comment block handling
- inbox-send.py: cross-project messaging via inbox directories
Diffstat (limited to '.ai/scripts/cj-remove-block.py')
| -rw-r--r-- | .ai/scripts/cj-remove-block.py | 101 |
1 files changed, 101 insertions, 0 deletions
diff --git a/.ai/scripts/cj-remove-block.py b/.ai/scripts/cj-remove-block.py new file mode 100644 index 0000000..71c7b3d --- /dev/null +++ b/.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()) |
