#!/usr/bin/env python3 """inbox-send — send text or a file to another project's top-level inbox/. Universal cross-project inbox messaging tool. A "project" here is a directory that contains both a `.ai/` marker (signalling it's a Claude-managed project) and a top-level `inbox/` directory (Craig's inbox convention). The script lets you drop a text message or copy a file into a target project's `inbox/`, with a dated filename that records the source project so the target's next session picks it up cleanly. Usage: inbox-send --list inbox-send --text "your message" [--name custom-slug] inbox-send --file [--name custom-slug] is the project's basename (or the numeric index from --list). Discovery roots default to ~/projects/ and ~/code/ (parent dirs whose children are scanned). Override with INBOX_SEND_ROOTS (colon-separated paths) or write paths into ~/.claude/inbox-roots.txt, one per line. A root may be either a parent directory or a specific project root (e.g. ~/.emacs.d); if the root itself is a project, it's included directly. """ from __future__ import annotations import argparse import os import re import shutil import sys import tempfile from datetime import datetime from pathlib import Path DEFAULT_ROOTS = [Path.home() / "projects", Path.home() / "code"] MAX_SLUG_LENGTH = 40 TS_FILENAME_FMT = "%Y-%m-%d-%H%M" TS_DOC_FMT = "%Y-%m-%d %H:%M:%S %z" def resolve_roots() -> list[Path]: """Resolve discovery roots: env var → config file → defaults.""" env_roots = os.environ.get("INBOX_SEND_ROOTS") if env_roots: return [Path(p) for p in env_roots.split(":") if p] config = Path.home() / ".claude" / "inbox-roots.txt" if config.is_file(): paths: list[Path] = [] for line in config.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#"): paths.append(Path(line).expanduser()) if paths: return paths return DEFAULT_ROOTS def _is_project(path: Path) -> bool: """A project has a `.ai/` marker AND a top-level `inbox/` directory.""" return (path / ".ai").is_dir() and (path / "inbox").is_dir() def discover_projects(roots: list[Path]) -> list[Path]: """Return absolute paths of projects discovered under `roots`. A root may be either a parent directory (its children are scanned) or 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): _add(root) continue for child in sorted(root.iterdir()): if not child.is_dir(): continue if _is_project(child): _add(child) return projects def find_current_project(start: Path) -> Path | None: """Walk up from `start` looking for the nearest dir containing .ai/.""" cur = start.resolve() while cur != cur.parent: if (cur / ".ai").is_dir(): return cur cur = cur.parent return None def slugify(text: str, max_length: int = MAX_SLUG_LENGTH) -> str: """Turn freeform text into a filename-safe slug.""" text = text.lower() text = re.sub(r"[^a-z0-9\s]+", " ", text) text = re.sub(r"\s+", " ", text).strip() if not text: return "" if len(text) <= max_length: return text.replace(" ", "-") # Truncate, then walk back to the last whitespace to keep a word boundary. truncated = text[:max_length] last_space = truncated.rfind(" ") if last_space > 0: truncated = truncated[:last_space] return truncated.strip().replace(" ", "-") def slugify_filename(stem: str, max_length: int = MAX_SLUG_LENGTH) -> str: """Slugify a filename stem while preserving its structure. Unlike slugify() (for freeform prose), a filename stem is already filename-safe and carries meaning in its separators — dots especially. The engine.plugin.org plugin-namespace convention encodes the engine/plugin boundary in the first dot, so flattening dots to hyphens corrupts the name. Keep [A-Za-z0-9._-] and case (e.g. the TOOLARGE- prefix convention), turn whitespace runs into single hyphens, drop anything else. """ stem = re.sub(r"\s+", "-", stem.strip()) stem = re.sub(r"[^A-Za-z0-9._-]+", "", stem) stem = re.sub(r"-{2,}", "-", stem).strip("-._") if not stem: return "" if len(stem) <= max_length: return stem truncated = stem[:max_length] # Walk back to the last separator so truncation doesn't cut mid-segment. last_sep = max(truncated.rfind("-"), truncated.rfind("."), truncated.rfind("_")) if last_sep > 0: truncated = truncated[:last_sep] return truncated.strip("-._") def display_name(path: Path) -> str: """The name a project is referred to by — its basename with dots stripped. Dotted directories (`.emacs.d`, `.dotfiles`) are awkward to name in conversation, so they're addressed dot-stripped: `emacsd`, `dotfiles`. """ return path.name.replace(".", "") def find_target(target_name: str, projects: list[Path]) -> Path | None: """Resolve `target_name` against the project list (basename or numeric index). An exact basename match wins. Failing that, a dot-stripped alias matches — so `emacsd` resolves `.emacs.d` and `dotfiles` resolves `.dotfiles`. """ if target_name.isdigit(): idx = int(target_name) - 1 if 0 <= idx < len(projects): return projects[idx] return None for p in projects: if p.name == target_name: return p norm = target_name.replace(".", "") for p in projects: if display_name(p) == norm: return p return None def build_text_org(message: str, source_name: str, timestamp: str) -> str: """Wrap a text message in a minimal org-mode skeleton.""" title = message.strip().splitlines()[0][:60] if message.strip() else "(empty)" return ( f"#+TITLE: {title}\n" f"#+SOURCE: from {source_name}\n" f"#+DATE: {timestamp}\n\n" f"{message.rstrip()}\n" ) def uniquify(dest: Path) -> Path: """Return dest, or dest with a -2/-3/... stem suffix when it already exists. Two sends in the same minute whose text starts with the same phrase derive identical filenames, and the second silently overwrote the first (a message was lost this way, 2026-07-02). Never overwrite. """ if not dest.exists(): return dest n = 2 while True: candidate = dest.with_name(f"{dest.stem}-{n}{dest.suffix}") if not candidate.exists(): return candidate 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, source_name: str, custom_name: str | None, now: datetime, ) -> Path: """Write a text message into target_inbox as a dated .org file.""" if not message.strip(): raise ValueError("--text cannot be empty or whitespace-only") slug = custom_name or slugify(message) if not slug: 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) 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 def send_file( target_inbox: Path, src_path: Path, source_name: str, custom_name: str | None, now: datetime, ) -> Path: """Copy src_path into target_inbox with a dated, source-tagged name.""" if not src_path.is_file(): raise FileNotFoundError(f"source file not found: {src_path}") slug = custom_name or slugify_filename(src_path.stem) if not slug: raise ValueError(f"could not derive a slug from file: {src_path}") ext = src_path.suffix filename = f"{now.strftime(TS_FILENAME_FMT)}-from-{source_name}-{slug}{ext}" dest = uniquify(target_inbox / filename) _atomic_write(dest, lambda p: shutil.copyfile(src_path, p)) return dest def print_project_list(projects: list[Path], current: Path | None) -> None: """Print numbered list of projects, with the current one excluded.""" others = [p for p in projects if current is None or p.resolve() != current.resolve()] if not others: print("No projects (.ai/ + inbox/) found under the configured roots.") return print(f"Available .ai projects ({len(others)}):") width = max(len(display_name(p)) for p in others) for i, p in enumerate(others, 1): print(f" {i}. {display_name(p):<{width}} {p}") def main() -> int: parser = argparse.ArgumentParser( description="Send text or a file to another .ai project's inbox/.", ) parser.add_argument( "--list", action="store_true", help="List available .ai projects and exit.", ) parser.add_argument( "target", nargs="?", help="Target project basename or numeric index from --list.", ) parser.add_argument( "--text", help="Text message to drop as an .org file in the target inbox.", ) parser.add_argument( "--file", type=Path, help="Path to a file to copy into the target inbox.", ) parser.add_argument( "--name", help="Override the auto-derived filename slug.", ) args = parser.parse_args() roots = resolve_roots() projects = discover_projects(roots) current = find_current_project(Path.cwd()) if args.list: print_project_list(projects, current) return 0 if not args.target: parser.error("must provide a target project (or --list)") if args.text is None and args.file is None: parser.error("must provide --text or --file") if args.text is not None and args.file is not None: parser.error("--text and --file are mutually exclusive") others = [p for p in projects if current is None or p.resolve() != current.resolve()] target = find_target(args.target, others) if target is None: print(f"inbox-send: unknown target {args.target!r}.", file=sys.stderr) print("Run `inbox-send --list` to see available projects.", file=sys.stderr) return 1 target_inbox = target / "inbox" if not target_inbox.is_dir(): print( f"inbox-send: target {target.name!r} has no top-level inbox/ directory.", file=sys.stderr, ) return 1 source_name = current.name if current else Path.cwd().name now = datetime.now().astimezone() try: if args.text is not None: dest = send_text(target_inbox, args.text, source_name, args.name, now) else: assert args.file is not None dest = send_file(target_inbox, args.file, source_name, args.name, now) 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: " error, never a traceback. print(f"inbox-send: {exc}", file=sys.stderr) return 1 print(f"Sent: {dest}") return 0 if __name__ == "__main__": sys.exit(main())