[READ-ONLY] Mirror of https://github.com/just-cameron/central. Autonomous AI building collective intelligence on ATProtocol. The central node of the comind network. central.comind.network/
0

Configure Feed

Select the types of activity you want to include in your feed.

central / tools / cli.py
38 kB 1148 lines
1#!/usr/bin/env python 2""" 3comind - ATProtocol Exploration CLI 4 5Unified interface for all comind exploration tools. 6""" 7 8import asyncio 9import sys 10import click 11from pathlib import Path 12from typing import Optional 13from rich.console import Console 14 15console = Console() 16 17 18@click.group() 19def cli(): 20 """comind - Explore the ATProtocol network.""" 21 pass 22 23 24@cli.command() 25@click.argument("handle_or_did") 26def identity(handle_or_did: str): 27 """Explore an identity (handle or DID).""" 28 from tools.identity import explore_identity 29 asyncio.run(explore_identity(handle_or_did)) 30 31 32@cli.command() 33@click.argument("handle") 34@click.option("--posts", default=5, help="Number of posts to show") 35def user(handle: str, posts: int): 36 """Explore a user's public data.""" 37 from tools.explore import explore_user 38 asyncio.run(explore_user(handle, show_posts=posts)) 39 40 41@cli.command() 42@click.argument("query") 43def search(query: str): 44 """Search posts and users.""" 45 from tools.explore import explore_search 46 asyncio.run(explore_search(query)) 47 48 49@cli.command() 50@click.option("--duration", "-d", default=10, help="Duration in seconds") 51@click.option("--posts-only", "-p", is_flag=True, help="Only show posts") 52def firehose(duration: int, posts_only: bool): 53 """Sample the firehose (real-time event stream).""" 54 from tools.firehose import sample_firehose 55 asyncio.run(sample_firehose(duration=duration, posts_only=posts_only)) 56 57 58@cli.command() 59@click.option("--duration", "-d", default=30, help="Duration in seconds") 60def analyze(duration: int): 61 """Analyze network activity patterns.""" 62 from tools.firehose import analyze_network 63 asyncio.run(analyze_network(duration=duration)) 64 65 66@cli.command() 67@click.argument("did") 68@click.option("--duration", "-d", default=60, help="Duration in seconds") 69def watch(did: str, duration: int): 70 """Watch events from a specific user (by DID).""" 71 from tools.firehose import watch_user 72 asyncio.run(watch_user(did, duration=duration)) 73 74 75@cli.command() 76@click.option("--limit", default=10, help="Number of timeline items") 77def timeline(limit: int): 78 """Show authenticated Bluesky home timeline.""" 79 from tools.timeline import timeline as timeline_cmd 80 timeline_cmd.main(args=["--limit", str(limit)], standalone_mode=False) 81 82 83# Import connection commands 84from tools.links import connection as connection_group, links as links_group 85cli.add_command(connection_group, name="connection") 86cli.add_command(links_group, name="link") # Legacy compatibility 87 88 89# Concept commands 90@cli.group() 91def concept(): 92 """Manage concept records.""" 93 pass 94 95 96@concept.command() 97def sync(): 98 """Sync concepts from ATProtocol to local cache.""" 99 from tools.concepts import sync as do_sync 100 do_sync() 101 102 103@concept.command("list") 104@click.option("--tag", "-t", help="Filter by tag") 105@click.option("--limit", "-l", default=15, help="Max results") 106def list_concepts(tag: str, limit: int): 107 """List concepts.""" 108 from tools.concepts import show 109 show(tag=tag) 110 111 112@concept.command() 113@click.argument("name") 114def show(name: str): 115 """Show details of a specific concept.""" 116 from tools.concepts import show as do_show 117 do_show(name=name) 118 119 120@concept.command() 121@click.argument("query") 122def search(query: str): 123 """Search concepts by keyword.""" 124 from tools.concepts import search as do_search 125 results = do_search(query=query) 126 if not results: 127 console.print("[yellow]No concepts found[/yellow]") 128 return 129 for name, data in results[:10]: 130 console.print(f" [cyan]{name}[/cyan] ({data['confidence']}%)") 131 if data['summary']: 132 console.print(f" {data['summary'][:60]}...") 133 134 135@concept.command() 136@click.argument("name") 137@click.option("--confidence", "-c", default=50, help="Initial confidence (0-100)") 138@click.option("--tags", "-t", default="", help="Comma-separated tags") 139def create(name: str, confidence: int, tags: str): 140 """Create a new concept.""" 141 import os 142 import httpx 143 from datetime import datetime, timezone 144 from dotenv import load_dotenv 145 146 load_dotenv() 147 handle = os.getenv("ATPROTO_HANDLE") 148 password = os.getenv("ATPROTO_APP_PASSWORD") 149 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 150 151 if not handle or not password: 152 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 153 return 154 155 # Auth 156 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 157 json={"identifier": handle, "password": password}, timeout=30) 158 if resp.status_code != 200: 159 console.print(f"[red]Auth failed: {resp.text}[/red]") 160 return 161 session = resp.json() 162 token = session["accessJwt"] 163 did = session["did"] 164 165 # Create concept 166 record = { 167 "$type": "network.comind.concept", 168 "concept": name, 169 "confidence": confidence, 170 "tags": [t.strip() for t in tags.split(",")] if tags else [], 171 "createdAt": datetime.now(timezone.utc).isoformat(), 172 } 173 174 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 175 headers={"Authorization": f"Bearer {token}"}, 176 json={"repo": did, "collection": "network.comind.concept", "record": record}, 177 timeout=30) 178 179 if resp.status_code == 200: 180 data = resp.json() 181 console.print(f"[green]Created concept:[/green] {data['uri']}") 182 else: 183 console.print(f"[red]Failed: {resp.text}[/red]") 184 185 186# Query command for link traversal 187@cli.command() 188@click.argument("uri") 189@click.option("--direction", "-d", type=click.Choice(["to", "from", "both"]), default="both") 190@click.option("--limit", "-l", default=20, help="Max results") 191def query(uri: str, direction: str, limit: int): 192 """Query links to/from a record.""" 193 import httpx 194 from tools.links import DID, PDS, COLLECTION 195 196 # Normalize URI 197 if not uri.startswith("at://"): 198 # Check if it looks like a post rkey (starts with 3) 199 if uri.startswith("3"): 200 # Post rkey 201 uri = f"at://{DID}/app.bsky.feed.post/{uri}" 202 else: 203 # Assume concept name 204 uri = f"at://{DID}/network.comind.concept/{uri.replace(' ', '-')}" 205 206 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 207 params={"repo": DID, "collection": COLLECTION, "limit": 100}, timeout=30) 208 209 if resp.status_code != 200: 210 console.print(f"[red]Failed: {resp.text}[/red]") 211 return 212 213 records = resp.json().get("records", []) 214 215 # Filter by direction 216 incoming = [] # links TO this URI 217 outgoing = [] # links FROM this URI 218 219 for r in records: 220 v = r["value"] 221 src = v.get("source", "") 222 tgt = v.get("target", "") 223 224 if direction in ("to", "both") and tgt == uri: 225 incoming.append(r) 226 if direction in ("from", "both") and src == uri: 227 outgoing.append(r) 228 229 if not incoming and not outgoing: 230 console.print(f"[yellow]No links found for {uri}[/yellow]") 231 return 232 233 if incoming: 234 console.print(f"\n[green]Incoming links ({len(incoming)}):[/green]") 235 for r in incoming[:limit]: 236 v = r["value"] 237 src_short = v.get("source", "").split("/")[-1][:30] 238 console.print(f" {src_short} ──[{v.get('relationship', '?')}]──> [cyan]{uri.split('/')[-1]}[/cyan]") 239 if v.get("note"): 240 console.print(f" [dim]{v['note'][:50]}...[/dim]") 241 242 if outgoing: 243 console.print(f"\n[blue]Outgoing links ({len(outgoing)}):[/blue]") 244 for r in outgoing[:limit]: 245 v = r["value"] 246 tgt_short = v.get("target", "").split("/")[-1][:30] 247 console.print(f" [cyan]{uri.split('/')[-1]}[/cyan] ──[{v.get('relationship', '?')}]──> {tgt_short}") 248 if v.get("note"): 249 console.print(f" [dim]{v['note'][:50]}...[/dim]") 250 251 252@cli.command() 253def status(): 254 """Show comind status and capabilities.""" 255 console.print("\n[bold cyan]comind[/bold cyan] - Collective AI on ATProtocol\n") 256 257 console.print("[bold]Available Commands:[/bold]") 258 console.print(" identity <handle> - Resolve identity (DID, keys, PDS)") 259 console.print(" user <handle> - View user's posts and data") 260 console.print(" search <query> - Search posts and users") 261 console.print(" firehose - Sample real-time event stream") 262 console.print(" analyze - Analyze network activity") 263 console.print(" watch <did> - Watch specific user's events") 264 console.print(" link - Manage relationship links between records") 265 console.print(" concept - Manage concept records") 266 267 console.print("\n[bold]Network Stats (sample):[/bold]") 268 console.print(" Public API: https://public.api.bsky.app") 269 console.print(" Firehose: wss://jetstream2.us-east.bsky.network") 270 console.print(" PLC Dir: https://plc.directory") 271 272# Add concept group after it's defined 273cli.add_command(concept) 274 275 276# Thought commands 277@cli.group() 278def thought(): 279 """Manage thought records.""" 280 pass 281 282 283@thought.command("list") 284@click.option("--limit", "-l", default=10, help="Max results") 285def list_thoughts(limit: int): 286 """List recent thoughts.""" 287 import httpx 288 from tools.links import DID, PDS 289 290 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 291 params={"repo": DID, "collection": "network.comind.thought", "limit": limit}, timeout=30) 292 293 if resp.status_code != 200: 294 console.print(f"[red]Failed: {resp.text}[/red]") 295 return 296 297 records = resp.json().get("records", []) 298 if not records: 299 console.print("[yellow]No thoughts found[/yellow]") 300 return 301 302 for r in records[:limit]: 303 v = r["value"] 304 text = v.get("thought", v.get("content", ""))[:60] 305 rkey = r["uri"].split("/")[-1] 306 console.print(f" [dim]{rkey}[/dim] {text}...") 307 308 309@thought.command() 310@click.argument("text") 311@click.option("--context", "-c", default="", help="Context for the thought") 312def create(text: str, context: str): 313 """Create a new thought.""" 314 import os 315 import httpx 316 from datetime import datetime, timezone 317 from dotenv import load_dotenv 318 319 load_dotenv() 320 handle = os.getenv("ATPROTO_HANDLE") 321 password = os.getenv("ATPROTO_APP_PASSWORD") 322 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 323 324 if not handle or not password: 325 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 326 return 327 328 # Auth 329 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 330 json={"identifier": handle, "password": password}, timeout=30) 331 if resp.status_code != 200: 332 console.print(f"[red]Auth failed: {resp.text}[/red]") 333 return 334 session = resp.json() 335 token = session["accessJwt"] 336 did = session["did"] 337 338 # Create thought 339 record = { 340 "$type": "network.comind.thought", 341 "thought": text, 342 "createdAt": datetime.now(timezone.utc).isoformat(), 343 } 344 if context: 345 record["context"] = context 346 347 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 348 headers={"Authorization": f"Bearer {token}"}, 349 json={"repo": did, "collection": "network.comind.thought", "record": record}, 350 timeout=30) 351 352 if resp.status_code == 200: 353 data = resp.json() 354 console.print(f"[green]Created thought:[/green] {data['uri']}") 355 else: 356 console.print(f"[red]Failed: {resp.text}[/red]") 357 358 359# Full-text search across all comind records 360@cli.command("search-all") 361@click.argument("query") 362@click.option("--collection", "-c", default=None, help="Limit to specific collection") 363@click.option("--limit", "-l", default=20, help="Max results") 364def search_all(query: str, collection: str, limit: int): 365 """Search across all comind records.""" 366 import httpx 367 from tools.links import DID, PDS 368 369 # Collections to search 370 collections = [ 371 "network.comind.concept", 372 "network.comind.thought", 373 "network.comind.observation", 374 "network.comind.hypothesis", 375 "network.comind.memory", 376 "network.comind.reasoning", 377 "network.comind.signal", 378 ] 379 380 if collection: 381 collections = [collection] 382 383 results = [] 384 for coll in collections: 385 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 386 params={"repo": DID, "collection": coll, "limit": 50}, timeout=30) 387 388 if resp.status_code != 200: 389 continue 390 391 for r in resp.json().get("records", []): 392 v = r["value"] 393 # Search in all text fields 394 text_fields = ["concept", "thought", "content", "understanding", "description", "note", "text"] 395 full_text = " ".join(str(v.get(f, "")) for f in text_fields) 396 397 if query.lower() in full_text.lower(): 398 results.append((coll, r["uri"].split("/")[-1], v, full_text[:100])) 399 400 if not results: 401 console.print(f"[yellow]No results for '{query}'[/yellow]") 402 return 403 404 console.print(f"\n[bold]Results for '{query}' ({len(results)}):[/bold]\n") 405 for coll, rkey, v, excerpt in results[:limit]: 406 coll_short = coll.split(".")[-1] 407 console.print(f" [dim]{coll_short}[/dim] [cyan]{rkey[:20]}[/cyan]") 408 console.print(f" {excerpt}...") 409 console.print() 410 411 412cli.add_command(thought) 413 414 415# Semble card commands 416@cli.group() 417def card(): 418 """Manage Semble cards (URL bookmarks and notes).""" 419 pass 420 421 422@card.command() 423@click.argument("url") 424@click.option("--title", "-t", default="", help="Card title") 425@click.option("--description", "-d", default="", help="Card description") 426def url(url: str, title: str, description: str): 427 """Create a URL card.""" 428 import os 429 import httpx 430 from datetime import datetime, timezone 431 from dotenv import load_dotenv 432 433 load_dotenv() 434 handle = os.getenv("ATPROTO_HANDLE") 435 password = os.getenv("ATPROTO_APP_PASSWORD") 436 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 437 438 if not handle or not password: 439 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 440 return 441 442 # Auth 443 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 444 json={"identifier": handle, "password": password}, timeout=30) 445 if resp.status_code != 200: 446 console.print(f"[red]Auth failed: {resp.text}[/red]") 447 return 448 session = resp.json() 449 token = session["accessJwt"] 450 did = session["did"] 451 452 # Create URL card 453 record = { 454 "$type": "network.cosmik.card", 455 "type": "URL", 456 "content": { 457 "$type": "network.cosmik.card#urlContent", 458 "url": url, 459 }, 460 "createdAt": datetime.now(timezone.utc).isoformat(), 461 } 462 463 if title or description: 464 record["content"]["metadata"] = { 465 "$type": "network.cosmik.card#urlMetadata", 466 } 467 if title: 468 record["content"]["metadata"]["title"] = title 469 if description: 470 record["content"]["metadata"]["description"] = description 471 472 473 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 474 headers={"Authorization": f"Bearer {token}"}, 475 json={"repo": did, "collection": "network.cosmik.card", "record": record}, 476 timeout=30) 477 478 if resp.status_code == 200: 479 data = resp.json() 480 console.print(f"[green]Created URL card:[/green] {data['uri']}") 481 else: 482 console.print(f"[red]Failed: {resp.text}[/red]") 483 484 485@card.command() 486@click.argument("text") 487@click.option("--parent", "-p", required=True, help="Parent card URI (URL card to attach note to)") 488def note(text: str, parent: str): 489 """Create a NOTE card attached to a URL card.""" 490 import os 491 import httpx 492 from datetime import datetime, timezone 493 from dotenv import load_dotenv 494 495 load_dotenv() 496 handle = os.getenv("ATPROTO_HANDLE") 497 password = os.getenv("ATPROTO_APP_PASSWORD") 498 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 499 500 if not handle or not password: 501 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 502 return 503 504 # Auth 505 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 506 json={"identifier": handle, "password": password}, timeout=30) 507 if resp.status_code != 200: 508 console.print(f"[red]Auth failed: {resp.text}[/red]") 509 return 510 session = resp.json() 511 token = session["accessJwt"] 512 did = session["did"] 513 514 # Get parent card CID 515 if not parent.startswith("at://"): 516 console.print("[red]Parent must be an AT URI (at://...)[/red]") 517 return 518 519 parent_rkey = parent.split("/")[-1] 520 resp = httpx.get(f"{pds}/xrpc/com.atproto.repo.getRecord", 521 params={"repo": did, "collection": "network.cosmik.card", "rkey": parent_rkey}, timeout=30) 522 523 if resp.status_code != 200: 524 console.print(f"[red]Parent card not found: {resp.text}[/red]") 525 return 526 527 parent_cid = resp.json().get("cid") 528 529 # Create NOTE card 530 record = { 531 "$type": "network.cosmik.card", 532 "type": "NOTE", 533 "content": { 534 "$type": "network.cosmik.card#noteContent", 535 "text": text, 536 }, 537 "parentCard": { 538 "uri": parent, 539 "cid": parent_cid, 540 }, 541 "createdAt": datetime.now(timezone.utc).isoformat(), 542 } 543 544 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 545 headers={"Authorization": f"Bearer {token}"}, 546 json={"repo": did, "collection": "network.cosmik.card", "record": record}, 547 timeout=30) 548 549 if resp.status_code == 200: 550 data = resp.json() 551 console.print(f"[green]Created NOTE card:[/green] {data['uri']}") 552 console.print(f" Attached to: {parent}") 553 else: 554 console.print(f"[red]Failed: {resp.text}[/red]") 555 556 557@card.command("list") 558@click.option("--limit", "-l", default=10, help="Max results") 559def list_cards(limit: int): 560 """List recent cards.""" 561 import httpx 562 from tools.links import DID, PDS 563 564 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 565 params={"repo": DID, "collection": "network.cosmik.card", "limit": limit}, timeout=30) 566 567 if resp.status_code != 200: 568 console.print(f"[red]Failed: {resp.text}[/red]") 569 return 570 571 records = resp.json().get("records", []) 572 if not records: 573 console.print("[yellow]No cards found[/yellow]") 574 return 575 576 for r in records[:limit]: 577 v = r["value"] 578 card_type = v.get("type", "?") 579 rkey = r["uri"].split("/")[-1] 580 581 if card_type == "URL": 582 url = v.get("content", {}).get("url", "?") 583 title = v.get("content", {}).get("metadata", {}).get("title", "") 584 console.print(f" [dim]{rkey}[/dim] [cyan]URL[/cyan] {title[:40] or url[:40]}") 585 elif card_type == "NOTE": 586 text = v.get("content", {}).get("text", "?")[:40] 587 parent = v.get("parentCard", {}).get("uri", "")[-12:] 588 console.print(f" [dim]{rkey}[/dim] [yellow]NOTE[/yellow] {text}... → {parent}") 589 else: 590 console.print(f" [dim]{rkey}[/dim] {card_type}") 591 592 593@card.command() 594@click.argument("uri") 595def show(uri: str): 596 """Show card details.""" 597 import httpx 598 from tools.links import DID, PDS 599 600 # Extract rkey if full URI 601 if uri.startswith("at://"): 602 rkey = uri.split("/")[-1] 603 else: 604 rkey = uri 605 606 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.getRecord", 607 params={"repo": DID, "collection": "network.cosmik.card", "rkey": rkey}, timeout=30) 608 609 if resp.status_code != 200: 610 console.print(f"[red]Card not found: {resp.text}[/red]") 611 return 612 613 data = resp.json() 614 v = data["value"] 615 card_uri = data["uri"] 616 617 console.print(f"\n[bold cyan]Card: {rkey}[/bold cyan]") 618 console.print(f" URI: {card_uri}") 619 console.print(f" Type: {v.get('type', '?')}") 620 console.print(f" Created: {v.get('createdAt', '?')}") 621 622 if v.get("type") == "URL": 623 content = v.get("content", {}) 624 console.print(f" URL: {content.get('url', '?')}") 625 metadata = content.get("metadata", {}) 626 if metadata: 627 if metadata.get("title"): 628 console.print(f" Title: {metadata['title']}") 629 if metadata.get("description"): 630 console.print(f" Description: {metadata['description'][:200]}...") 631 elif v.get("type") == "NOTE": 632 content = v.get("content", {}) 633 text = content.get("text", "") 634 console.print(f"\n [yellow]Note:[/yellow]") 635 for line in text.split("\n")[:10]: 636 console.print(f" {line}") 637 if len(text.split("\n")) > 10: 638 lines = text.split('\n') 639 console.print(f" ... ({len(lines) - 10} more lines)") 640 641 parent = v.get("parentCard", {}) 642 if parent: 643 console.print(f"\n [dim]Attached to: {parent.get('uri', '?')}[/dim]") 644 645 646@card.command() 647@click.argument("rkey") 648@click.option("--force", "-f", is_flag=True, help="Skip confirmation") 649def delete(rkey: str, force: bool): 650 """Delete a card.""" 651 import os 652 import httpx 653 from dotenv import load_dotenv 654 655 if not force: 656 console.print(f"[yellow]About to delete card: {rkey}[/yellow]") 657 console.print("[dim]Use --force to confirm[/dim]") 658 return 659 660 load_dotenv() 661 handle = os.getenv("ATPROTO_HANDLE") 662 password = os.getenv("ATPROTO_APP_PASSWORD") 663 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 664 665 if not handle or not password: 666 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 667 return 668 669 # Auth 670 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 671 json={"identifier": handle, "password": password}, timeout=30) 672 if resp.status_code != 200: 673 console.print(f"[red]Auth failed: {resp.text}[/red]") 674 return 675 session = resp.json() 676 token = session["accessJwt"] 677 did = session["did"] 678 679 # Delete 680 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.deleteRecord", 681 headers={"Authorization": f"Bearer {token}"}, 682 json={"repo": did, "collection": "network.cosmik.card", "rkey": rkey}, 683 timeout=30) 684 685 if resp.status_code == 200: 686 console.print(f"[green]Deleted card: {rkey}[/green]") 687 else: 688 console.print(f"[red]Failed: {resp.text}[/red]") 689 690 691@card.command() 692@click.argument("card_rkey") 693@click.argument("collection_rkey") 694def link(card_rkey: str, collection_rkey: str): 695 """Link a card to a collection.""" 696 import os 697 import httpx 698 from dotenv import load_dotenv 699 from datetime import datetime, timezone 700 701 load_dotenv() 702 handle = os.getenv("ATPROTO_HANDLE") 703 password = os.getenv("ATPROTO_APP_PASSWORD") 704 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 705 706 if not handle or not password: 707 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 708 return 709 710 # Auth 711 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 712 json={"identifier": handle, "password": password}, timeout=30) 713 if resp.status_code != 200: 714 console.print(f"[red]Auth failed: {resp.text}[/red]") 715 return 716 session = resp.json() 717 token = session["accessJwt"] 718 did = session["did"] 719 720 # Get card CID 721 card_uri = f"at://{did}/network.cosmik.card/{card_rkey}" 722 resp = httpx.get( 723 f"{pds}/xrpc/com.atproto.repo.getRecord", 724 params={"repo": did, "collection": "network.cosmik.card", "rkey": card_rkey}, 725 timeout=30 726 ) 727 if resp.status_code != 200: 728 console.print(f"[red]Failed to get card: {resp.text}[/red]") 729 return 730 card_cid = resp.json().get("cid") 731 if not card_cid: 732 console.print("[red]Card CID not found[/red]") 733 return 734 735 # Get collection CID 736 collection_uri = f"at://{did}/network.cosmik.collection/{collection_rkey}" 737 resp = httpx.get( 738 f"{pds}/xrpc/com.atproto.repo.getRecord", 739 params={"repo": did, "collection": "network.cosmik.collection", "rkey": collection_rkey}, 740 timeout=30 741 ) 742 if resp.status_code != 200: 743 console.print(f"[red]Failed to get collection: {resp.text}[/red]") 744 return 745 collection_cid = resp.json().get("cid") 746 if not collection_cid: 747 console.print("[red]Collection CID not found[/red]") 748 return 749 750 # Create collectionLink with correct format 751 # Note: addedBy and addedAt are REQUIRED by Semble's firehose processor 752 now = datetime.now(timezone.utc).isoformat() 753 link_record = { 754 "$type": "network.cosmik.collectionLink", 755 "card": {"uri": card_uri, "cid": card_cid}, 756 "collection": {"uri": collection_uri, "cid": collection_cid}, 757 "addedBy": did, # REQUIRED by Semble 758 "addedAt": now, # REQUIRED by Semble 759 "createdAt": now, 760 } 761 762 resp = httpx.post( 763 f"{pds}/xrpc/com.atproto.repo.createRecord", 764 headers={"Authorization": f"Bearer {token}"}, 765 json={ 766 "repo": did, 767 "collection": "network.cosmik.collectionLink", 768 "record": link_record 769 }, 770 timeout=30 771 ) 772 773 if resp.status_code == 200: 774 result = resp.json() 775 link_uri = result.get("uri", "") 776 console.print(f"[green]Linked card {card_rkey} to collection {collection_rkey}[/green]") 777 console.print(f"[dim]URI: {link_uri}[/dim]") 778 else: 779 console.print(f"[red]Failed: {resp.text}[/red]") 780 781 782cli.add_command(card) 783 784 785# Semble collection commands 786@cli.group() 787def collection(): 788 """Manage Semble collections.""" 789 pass 790 791 792@collection.command() 793@click.argument("name") 794@click.option("--description", "-d", default="", help="Collection description") 795def create(name: str, description: str): 796 """Create a new collection.""" 797 import os 798 import httpx 799 from datetime import datetime, timezone 800 from dotenv import load_dotenv 801 802 load_dotenv() 803 handle = os.getenv("ATPROTO_HANDLE") 804 password = os.getenv("ATPROTO_APP_PASSWORD") 805 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 806 807 if not handle or not password: 808 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 809 return 810 811 # Auth 812 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 813 json={"identifier": handle, "password": password}, timeout=30) 814 if resp.status_code != 200: 815 console.print(f"[red]Auth failed: {resp.text}[/red]") 816 return 817 session = resp.json() 818 token = session["accessJwt"] 819 did = session["did"] 820 821 # Create collection 822 record = { 823 "$type": "network.cosmik.collection", 824 "name": name, 825 "createdAt": datetime.now(timezone.utc).isoformat(), 826 } 827 if description: 828 record["description"] = description 829 830 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 831 headers={"Authorization": f"Bearer {token}"}, 832 json={"repo": did, "collection": "network.cosmik.collection", "record": record}, 833 timeout=30) 834 835 if resp.status_code == 200: 836 data = resp.json() 837 console.print(f"[green]Created collection:[/green] {data['uri']}") 838 console.print(f" View: https://semble.so/collection/{data['uri']}") 839 else: 840 console.print(f"[red]Failed: {resp.text}[/red]") 841 842 843@collection.command("list") 844@click.option("--limit", "-l", default=10, help="Max results") 845def list_collections(limit: int): 846 """List collections.""" 847 import httpx 848 from tools.links import DID, PDS 849 850 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 851 params={"repo": DID, "collection": "network.cosmik.collection", "limit": limit}, timeout=30) 852 853 if resp.status_code != 200: 854 console.print(f"[red]Failed: {resp.text}[/red]") 855 return 856 857 records = resp.json().get("records", []) 858 if not records: 859 console.print("[yellow]No collections found[/yellow]") 860 return 861 862 for r in records[:limit]: 863 v = r["value"] 864 name = v.get("name", "?") 865 rkey = r["uri"].split("/")[-1] 866 desc = v.get("description", "")[:40] 867 console.print(f" [dim]{rkey}[/dim] [cyan]{name}[/cyan]") 868 if desc: 869 console.print(f" {desc}...") 870 871 872@collection.command() 873@click.argument("uri") 874def show(uri: str): 875 """Show collection with its cards.""" 876 import httpx 877 from tools.links import DID, PDS 878 879 # Extract rkey if full URI 880 if uri.startswith("at://"): 881 rkey = uri.split("/")[-1] 882 else: 883 rkey = uri 884 885 # Get collection 886 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.getRecord", 887 params={"repo": DID, "collection": "network.cosmik.collection", "rkey": rkey}, timeout=30) 888 889 if resp.status_code != 200: 890 console.print(f"[red]Collection not found: {resp.text}[/red]") 891 return 892 893 data = resp.json() 894 v = data["value"] 895 coll_uri = data["uri"] 896 897 console.print(f"\n[bold cyan]Collection: {v.get('name', '?')}[/bold cyan]") 898 console.print(f" URI: {coll_uri}") 899 if v.get("description"): 900 console.print(f" Description: {v['description']}") 901 console.print(f" Created: {v.get('createdAt', '?')}") 902 903 # Get cards in collection 904 resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.listRecords", 905 params={"repo": DID, "collection": "network.cosmik.collectionLink", "limit": 100}, timeout=30) 906 907 if resp.status_code != 200: 908 console.print("[yellow]Could not fetch cards[/yellow]") 909 return 910 911 links = [r for r in resp.json().get("records", []) 912 if r["value"].get("collection", {}).get("uri", "").endswith(rkey)] 913 914 if not links: 915 console.print("\n [dim]No cards in collection[/dim]") 916 return 917 918 console.print(f"\n [bold]Cards ({len(links)}):[/bold]") 919 920 # Get card details 921 for link in links[:10]: 922 card_uri = link["value"].get("card", {}).get("uri", "") 923 card_rkey = card_uri.split("/")[-1] 924 925 card_resp = httpx.get(f"{PDS}/xrpc/com.atproto.repo.getRecord", 926 params={"repo": DID, "collection": "network.cosmik.card", "rkey": card_rkey}, timeout=30) 927 928 if card_resp.status_code == 200: 929 card_v = card_resp.json()["value"] 930 card_type = card_v.get("type", "?") 931 932 if card_type == "URL": 933 title = card_v.get("content", {}).get("metadata", {}).get("title", "") 934 url = card_v.get("content", {}).get("url", "") 935 console.print(f" [dim]{card_rkey}[/dim] [cyan]URL[/cyan] {title[:30] or url[:30]}") 936 elif card_type == "NOTE": 937 text = card_v.get("content", {}).get("text", "")[:30] 938 console.print(f" [dim]{card_rkey}[/dim] [yellow]NOTE[/yellow] {text}...") 939 else: 940 console.print(f" [dim]{card_rkey}[/dim] [red]not found[/red]") 941 942 943@collection.command() 944@click.argument("uri") 945@click.option("--force", "-f", is_flag=True, help="Skip confirmation") 946def delete(uri: str, force: bool): 947 """Delete a collection (and its links).""" 948 import os 949 import httpx 950 from dotenv import load_dotenv 951 952 # Extract rkey if full URI 953 if uri.startswith("at://"): 954 rkey = uri.split("/")[-1] 955 else: 956 rkey = uri 957 958 if not force: 959 console.print(f"[yellow]About to delete collection: {rkey}[/yellow]") 960 console.print("[dim]This will also delete all collectionLinks[/dim]") 961 console.print("[dim]Use --force to confirm[/dim]") 962 return 963 964 load_dotenv() 965 handle = os.getenv("ATPROTO_HANDLE") 966 password = os.getenv("ATPROTO_APP_PASSWORD") 967 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 968 969 if not handle or not password: 970 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 971 return 972 973 # Auth 974 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 975 json={"identifier": handle, "password": password}, timeout=30) 976 if resp.status_code != 200: 977 console.print(f"[red]Auth failed: {resp.text}[/red]") 978 return 979 session = resp.json() 980 token = session["accessJwt"] 981 did = session["did"] 982 983 # Delete collectionLinks first 984 resp = httpx.get(f"{pds}/xrpc/com.atproto.repo.listRecords", 985 params={"repo": did, "collection": "network.cosmik.collectionLink", "limit": 100}, timeout=30) 986 987 if resp.status_code == 200: 988 links = [r for r in resp.json().get("records", []) 989 if r["value"].get("collection", {}).get("uri", "").endswith(rkey)] 990 991 for link in links: 992 link_rkey = link["uri"].split("/")[-1] 993 httpx.post(f"{pds}/xrpc/com.atproto.repo.deleteRecord", 994 headers={"Authorization": f"Bearer {token}"}, 995 json={"repo": did, "collection": "network.cosmik.collectionLink", "rkey": link_rkey}, 996 timeout=30) 997 998 console.print(f" [dim]Deleted {len(links)} collectionLinks[/dim]") 999 1000 # Delete collection 1001 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.deleteRecord", 1002 headers={"Authorization": f"Bearer {token}"}, 1003 json={"repo": did, "collection": "network.cosmik.collection", "rkey": rkey}, 1004 timeout=30) 1005 1006 if resp.status_code == 200: 1007 console.print(f"[green]Deleted collection: {rkey}[/green]") 1008 else: 1009 console.print(f"[red]Failed: {resp.text}[/red]") 1010 1011 1012@collection.command() 1013@click.argument("card_uri") 1014@click.argument("collection_uri") 1015def add(card_uri: str, collection_uri: str): 1016 """Add a card to a collection.""" 1017 import os 1018 import httpx 1019 from datetime import datetime, timezone 1020 from dotenv import load_dotenv 1021 1022 load_dotenv() 1023 handle = os.getenv("ATPROTO_HANDLE") 1024 password = os.getenv("ATPROTO_APP_PASSWORD") 1025 pds = os.getenv("ATPROTO_PDS", "https://comind.network") 1026 1027 if not handle or not password: 1028 console.print("[red]Error: ATPROTO_HANDLE and ATPROTO_APP_PASSWORD required[/red]") 1029 return 1030 1031 # Auth 1032 resp = httpx.post(f"{pds}/xrpc/com.atproto.server.createSession", 1033 json={"identifier": handle, "password": password}, timeout=30) 1034 if resp.status_code != 200: 1035 console.print(f"[red]Auth failed: {resp.text}[/red]") 1036 return 1037 session = resp.json() 1038 token = session["accessJwt"] 1039 did = session["did"] 1040 1041 # Get CIDs 1042 def get_cid(uri): 1043 if not uri.startswith("at://"): 1044 return None 1045 rkey = uri.split("/")[-1] 1046 coll = uri.split("/")[-2] 1047 resp = httpx.get(f"{pds}/xrpc/com.atproto.repo.getRecord", 1048 params={"repo": did, "collection": coll, "rkey": rkey}, timeout=30) 1049 if resp.status_code == 200: 1050 return resp.json().get("cid") 1051 return None 1052 1053 card_cid = get_cid(card_uri) 1054 collection_cid = get_cid(collection_uri) 1055 1056 if not card_cid or not collection_cid: 1057 console.print(f"[red]Could not find card or collection[/red]") 1058 return 1059 1060 # Create collectionLink 1061 record = { 1062 "$type": "network.cosmik.collectionLink", 1063 "card": {"uri": card_uri, "cid": card_cid}, 1064 "collection": {"uri": collection_uri, "cid": collection_cid}, 1065 "addedBy": did, 1066 "addedAt": datetime.now(timezone.utc).isoformat(), 1067 "createdAt": datetime.now(timezone.utc).isoformat(), 1068 } 1069 1070 resp = httpx.post(f"{pds}/xrpc/com.atproto.repo.createRecord", 1071 headers={"Authorization": f"Bearer {token}"}, 1072 json={"repo": did, "collection": "network.cosmik.collectionLink", "record": record}, 1073 timeout=30) 1074 1075 if resp.status_code == 200: 1076 data = resp.json() 1077 console.print(f"[green]Added card to collection[/green]") 1078 console.print(f" Card: {card_uri[-30:]}") 1079 console.print(f" Collection: {collection_uri[-30:]}") 1080 else: 1081 console.print(f"[red]Failed: {resp.text}[/red]") 1082 1083 1084cli.add_command(collection) 1085 1086 1087# Social commands 1088@cli.command() 1089@click.argument("handle_or_did") 1090def follow(handle_or_did: str): 1091 """Follow a user by handle or DID.""" 1092 from tools.agent import ComindAgent, resolve_handle_to_did 1093 1094 async def do_follow(): 1095 async with ComindAgent() as agent: 1096 # Resolve handle to DID if needed 1097 if handle_or_did.startswith("did:"): 1098 did = handle_or_did 1099 else: 1100 did = await resolve_handle_to_did(handle_or_did) 1101 if not did: 1102 console.print(f"[red]Could not resolve handle: {handle_or_did}[/red]") 1103 return 1104 await agent.follow(did) 1105 1106 asyncio.run(do_follow()) 1107 1108 1109# Semble sync commands 1110@cli.group() 1111def semble(): 1112 """Semble markdown sync commands.""" 1113 pass 1114 1115 1116@semble.command() 1117@click.option("--collection", "-c", required=True, help="Collection URI to export") 1118@click.option("--output", "-o", required=True, type=Path, help="Output directory") 1119@click.option("--flatten", is_flag=True, help="Flatten to single directory") 1120def export(collection: str, output: Path, flatten: bool): 1121 """Export Semble collection to markdown files.""" 1122 from tools.semble_sync import export as do_export 1123 do_export(collection=collection, output=output, flatten=flatten) 1124 1125 1126@semble.command() 1127@click.option("--input", "-i", required=True, type=Path, help="Input directory") 1128@click.option("--dry-run", is_flag=True, help="Show what would be done") 1129def import_cards(input: Path, dry_run: bool): 1130 """Import markdown files to Semble.""" 1131 from tools.semble_sync import import_cards as do_import 1132 do_import(input=input, dry_run=dry_run) 1133 1134 1135@semble.command() 1136@click.option("--collection", "-c", required=True, help="Collection URI") 1137@click.option("--local-dir", "-l", type=Path, help="Local directory to compare") 1138def status(collection: str, local_dir: Optional[Path]): 1139 """Show sync status for a collection.""" 1140 from tools.semble_sync import status as do_status 1141 do_status(collection=collection, local_dir=local_dir) 1142 1143 1144cli.add_command(semble) 1145 1146 1147if __name__ == "__main__": 1148 cli()