[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/
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()