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/inbox-send.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/inbox-send.py')
| -rw-r--r-- | .ai/scripts/inbox-send.py | 262 |
1 files changed, 262 insertions, 0 deletions
diff --git a/.ai/scripts/inbox-send.py b/.ai/scripts/inbox-send.py new file mode 100644 index 0000000..8e650ff --- /dev/null +++ b/.ai/scripts/inbox-send.py @@ -0,0 +1,262 @@ +#!/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 <target> --text "your message" [--name custom-slug] + inbox-send <target> --file <path> [--name custom-slug] + +<target> 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 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(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()) |
