diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index 69e7f6b..7f906d3 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -94,29 +94,37 @@ def cmd_sync(args): # Initialize an empty outbox template if it doesn't exist if not os.path.exists("outbox.yaml"): + template_header = """# VOID OUTBOX SCHEMA +# This file is processed by `void-cli dispatch`. +# +# Supported Actions: +# +# 1. Standalone Post or Thread +# - action: post +# posts: +# - "First post text" +# - "Second post text (automatically threads)" +# +# 2. Reply +# - action: reply +# target_id: "notif_0" # Resolves from inbox.yaml +# posts: +# - "My reply text" +# +# 3. Margin Annotation +# - action: annotate +# target_id: "notif_1" # Or explicit at:// URI +# text: "My archival observation" +# motivation: "commenting" # Optional: commenting, highlighting, describing +# quote: "exact text to anchor to" # Optional +# +""" template = { "config": {"mark_seen": False}, - "actions": [ - { - "action": "reply", - "target_id": "notif_0", - "posts": ["First post of reply thread", "Second post"] - }, - { - "action": "post", - "posts": ["Standalone post"] - }, - { - "action": "annotate", - "target_id": "notif_0", - "text": "Internal context or observation about this post.", - "motivation": "commenting", - "quote": "optional exact text to highlight" - } - ] + "actions": [] } with open("outbox.yaml", "w") as f: - f.write("# Example outbox\\n") + f.write(template_header) yaml.dump(template, f, sort_keys=False, default_flow_style=False) except Exception as e: @@ -383,6 +391,51 @@ def cmd_profile(args): print(f"Error updating profile: {e}", file=sys.stderr) sys.exit(1) +def cmd_annotate(args): + client = get_client() + target_uri = args.target_uri + + # Convert AT-URI to bsky.app HTTP URL for Margin compatibility + if target_uri.startswith("at://"): + parts = target_uri.replace("at://", "").split("/") + if len(parts) >= 3: + repo = parts[0] + rkey = parts[2] + target_uri = f"https://bsky.app/profile/{repo}/post/{rkey}" + + print(f"Dispatching annotation to {target_uri}...") + + try: + source_hash = hashlib.sha256(target_uri.encode()).hexdigest() + record = { + "$type": "at.margin.annotation", + "createdAt": client.get_current_time_iso(), + "target": { + "source": target_uri, + "sourceHash": source_hash, + }, + "body": { + "value": args.text + }, + "motivation": args.motivation + } + + if args.quote: + record["target"]["selector"] = { + "type": "TextQuoteSelector", + "exact": args.quote + } + + resp = client.com.atproto.repo.create_record({ + 'repo': client.me.did, + 'collection': 'at.margin.annotation', + 'record': record + }) + print(f"Successfully annotated: {resp.uri}") + except Exception as e: + print(f"Error annotating {target_uri}: {e}", file=sys.stderr) + sys.exit(1) + def main(): parser = argparse.ArgumentParser(description="Void ATProto CLI Tools") subparsers = parser.add_subparsers(dest="command", required=True) @@ -424,6 +477,13 @@ def main(): dispatch_parser = subparsers.add_parser("dispatch", help="Dispatch posts from an outbox YAML file") dispatch_parser.add_argument("file", nargs="?", default="outbox.yaml", help="Path to outbox YAML file") + # Annotate + annotate_parser = subparsers.add_parser("annotate", help="Directly annotate a URI") + annotate_parser.add_argument("target_uri", help="The AT-URI or HTTP URL to annotate") + annotate_parser.add_argument("text", help="Annotation text") + annotate_parser.add_argument("--motivation", default="commenting", help="W3C motivation (default: commenting)") + annotate_parser.add_argument("--quote", help="Exact text to anchor the annotation to") + args = parser.parse_args() if args.command == "notifs": @@ -438,6 +498,8 @@ def main(): cmd_search(args) elif args.command == "profile": cmd_profile(args) + elif args.command == "annotate": + cmd_annotate(args) if __name__ == "__main__": main()