#!/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 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().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] = [] for root in roots: if not root.is_dir(): continue if _is_project(root): projects.append(root) continue for child in sorted(root.iterdir()): if not child.is_dir(): continue if _is_project(child): projects.append(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 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) dest.write_text(build_text_org(message, source_name, now.strftime(TS_DOC_FMT))) 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) shutil.copy2(src_path, dest) 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, FileNotFoundError) as exc: print(f"inbox-send: {exc}", file=sys.stderr) return 1 print(f"Sent: {dest}") return 0 if __name__ == "__main__": sys.exit(main())