#!/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 find_target(target_name: str, projects: list[Path]) -> Path | None: """Resolve `target_name` against the project list (basename or numeric index).""" 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 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 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 = 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 = 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(p.name) for p in others) for i, p in enumerate(others, 1): print(f" {i}. {p.name:<{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())