aboutsummaryrefslogtreecommitdiff
path: root/claude-templates/.ai/scripts/inbox-send.py
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates/.ai/scripts/inbox-send.py')
-rwxr-xr-xclaude-templates/.ai/scripts/inbox-send.py61
1 files changed, 55 insertions, 6 deletions
diff --git a/claude-templates/.ai/scripts/inbox-send.py b/claude-templates/.ai/scripts/inbox-send.py
index 1ebb636..663efcb 100755
--- a/claude-templates/.ai/scripts/inbox-send.py
+++ b/claude-templates/.ai/scripts/inbox-send.py
@@ -31,6 +31,7 @@ import os
import re
import shutil
import sys
+import tempfile
from datetime import datetime
from pathlib import Path
@@ -48,7 +49,7 @@ def resolve_roots() -> list[Path]:
config = Path.home() / ".claude" / "inbox-roots.txt"
if config.is_file():
paths: list[Path] = []
- for line in config.read_text().splitlines():
+ for line in config.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(line).expanduser())
@@ -69,17 +70,28 @@ def discover_projects(roots: list[Path]) -> list[Path]:
a specific project root (included directly if it qualifies).
"""
projects: list[Path] = []
+ seen: set[Path] = set()
+
+ def _add(p: Path) -> None:
+ # Dedupe on the resolved path: a roots config naming both a parent and
+ # one of its children would otherwise list the child project twice, at
+ # two different indices.
+ key = p.resolve()
+ if key not in seen:
+ seen.add(key)
+ projects.append(p)
+
for root in roots:
if not root.is_dir():
continue
if _is_project(root):
- projects.append(root)
+ _add(root)
continue
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
if _is_project(child):
- projects.append(child)
+ _add(child)
return projects
@@ -194,6 +206,39 @@ def uniquify(dest: Path) -> Path:
n += 1
+def _atomic_write(dest: Path, writer) -> None:
+ """Write to a temp file in dest's directory, then rename it into place.
+
+ dest is another project's inbox/, and a direct write truncates the target
+ on open, so any mid-write failure (a full disk, an encoding error, an
+ interrupted process) leaves a zero-byte .org there. inbox-status counts
+ that phantom as a pending handoff and blocks a turn in the receiving
+ project over a file with no content and no sender (2026-07-23). Writing to
+ a temp sibling and os.replace-ing means the inbox only ever sees a complete
+ file. os.replace is atomic within one filesystem, and the temp sits in the
+ same directory as dest, so it is.
+
+ `writer` receives the open temp path and fills it. On any failure the temp
+ is removed and the error re-raised, so a caught error never leaves debris.
+ """
+ fd, tmp = tempfile.mkstemp(
+ dir=dest.parent, prefix=".inbox-send-", suffix=dest.suffix
+ )
+ os.close(fd)
+ tmp_path = Path(tmp)
+ # mkstemp creates the temp 0600; give the delivered file the umask-default
+ # mode the old direct write produced, so inbox files stay readable as before.
+ umask = os.umask(0)
+ os.umask(umask)
+ os.chmod(tmp_path, 0o666 & ~umask)
+ try:
+ writer(tmp_path)
+ os.replace(tmp_path, dest)
+ except BaseException:
+ tmp_path.unlink(missing_ok=True)
+ raise
+
+
def send_text(
target_inbox: Path,
message: str,
@@ -209,7 +254,8 @@ def send_text(
raise ValueError(f"could not derive a slug from text: {message!r}")
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}.org"
dest = uniquify(target_inbox / filename)
- dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT)))
+ body = build_text_org(message, source_name, now.strftime(TS_DOC_FMT))
+ _atomic_write(dest, lambda p: p.write_text(body, encoding="utf-8"))
return dest
@@ -229,7 +275,7 @@ def send_file(
ext = src_path.suffix
filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}"
dest = uniquify(target_inbox / filename)
- shutil.copy2(src_path, dest)
+ _atomic_write(dest, lambda p: shutil.copyfile(src_path, p))
return dest
@@ -310,7 +356,10 @@ def main() -> int:
else:
assert args.file is not None
dest = send_file(target_inbox, args.file, source_name, args.name, now)
- except (ValueError, FileNotFoundError) as exc:
+ except (ValueError, OSError) as exc:
+ # OSError covers FileNotFoundError (missing source), PermissionError
+ # (unreadable source), and any atomic-write failure — all should
+ # surface as the clean "inbox-send: <message>" error, never a traceback.
print(f"inbox-send: {exc}", file=sys.stderr)
return 1