aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/inbox-send.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
commitee721ee96f984ccd38233309f0dfe6362057e644 (patch)
treef84a3b21ae846c82a2677a59f54947ee5b557174 /.ai/scripts/inbox-send.py
parent421b17a15219c7061ee92c07451993965fad88ea (diff)
downloadrulesets-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.py262
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())