""" dossier — CLI for Context Dossier. """ import argparse import json import os import sys import textwrap from pathlib import Path from . import db as _db from .config import CtxConfig, DEFAULT_HOME _WELCOME_CONTENT = """# Welcome Welcome to ctxd! This is the shared context store for your projects. ## Getting Started 1. **Create a project:** `ctx project-create my-project --name "My Project"` 2. **Edit context:** `ctx edit my-project` 3. **Start the daemon:** `ctx serve` 4. **Sync to repo:** `ctx sync my-project /path/to/project/root` ## Commands | Command | Description | |---|---| | `ctx init` | Initialize the context store | | `ctx serve` | Start the daemon | | `ctx edit ` | Edit project context | | `ctx read ` | Read project context | | `ctx sync [path]` | Sync to project root | | `ctx search ` | Full-text search | | `ctx project-list` | List all projects | | `ctx audit` | Show audit log | | `ctx user-list` | List users | """ _REMOTE_RIG_CONTENT = """# RemoteRig Multi-camera remote monitoring system. ## Architecture - **Go backend** for the hub/controller app - **ESP32-C6 camera nodes** (Seeed Studio XIAO ESP32-C6) - **Pi Zero 2 W** display hub with 10.1-inch touchscreen - **Home Assistant** integration via Frigate ## Key Decisions - Go for production code, Python for diagnostics and hardware validation - Code in Gitea, hardware/design in Notion, CAD artifacts on Seafile - Enclosure preserves larger case size for wiring clearance ## Conventions - Development branches off `dev`, PRs target `dev` never `main` - All feature work tracked in Linear (team `CUB`) """ def _seed_context(conn): """Insert seed project context with real newlines.""" from ctxd.db import _is_pg, _ph ph = _ph(conn, 2) placeholders = ph.split(", ") if _is_pg(conn): sql = f"INSERT INTO project_context (project_id, content, version, updated_by) VALUES ({placeholders[0]}, {placeholders[1]}, 0, 'admin') ON CONFLICT DO NOTHING" else: sql = f"INSERT OR IGNORE INTO project_context (project_id, content, version, updated_by) VALUES ({placeholders[0]}, {placeholders[1]}, 0, 'admin')" conn.execute(sql, ('welcome', _WELCOME_CONTENT)) conn.execute(sql, ('remote-rig', _REMOTE_RIG_CONTENT)) def cmd_init(args): """Initialize ~/.ctx/ with database and seed data.""" home = Path(args.home or DEFAULT_HOME).resolve() cfg = CtxConfig(home=home) if cfg.db_path.exists(): print(f"ctxd already initialized at {home}") print(f" DB: {cfg.db_path}") print("Run 'ctx serve' to start the daemon.") return conn = _db.init_db(cfg) cfg.save() # Seed project context with real newlines (SQL strings can't do this) _seed_context(conn) conn.commit() conn.close() print(f"✓ ctxd initialized at {home}") print(f" Database: {cfg.db_path}") print(f" Config: {cfg.config_path}") print(f" Projects: {cfg.projects_dir}") print(f" Users: admin (admin), hermes-gateway (service)") print(f" Projects: welcome, remote-rig (seed)") print() print("Next steps:") print(" 1. Edit project context: ctx edit ") print(" 2. Start the daemon: ctx serve") print(" 3. Set sync path: ctx sync ") def cmd_serve(args): """Start the ctxd daemon.""" cfg = CtxConfig.from_home(args.home) if not cfg.db_path.exists(): print("Not initialized. Run 'ctx init' first.") sys.exit(1) from .server import serve_sync serve_sync(cfg) def cmd_project_list(args): """List all projects.""" conn = _db.init_db(CtxConfig.from_home(args.home)) projects = _db.project_list(conn) conn.close() if not projects: print("No projects found.") return print(f"{'PROJECT ID':<20} {'DISPLAY NAME':<25} {'VERSION':>8} SYNC PATH") print("-" * 80) for p in projects: print(f"{p['project_id']:<20} {p['display_name']:<25} {p['shared_version']:>8} {p.get('sync_path', '') or '-'}") def cmd_project_create(args): """Create a new project.""" conn = _db.init_db(CtxConfig.from_home(args.home)) try: _db.project_create(conn, args.project_id, args.name or args.project_id, args.description or "") _db.audit_log(conn, "admin", "create", f"Created project {args.project_id}", project_id=args.project_id, entity_type="project", entity_id=args.project_id) conn.commit() print(f"✓ Project '{args.project_id}' created.") except Exception as e: conn.rollback() print(f"Error: {e}") finally: conn.close() def cmd_context_read(args): """Read project context to stdout.""" conn = _db.init_db(CtxConfig.from_home(args.home)) ctx = _db.context_read(conn, args.project_id) conn.close() if ctx is None: print(f"Project '{args.project_id}' not found.") sys.exit(1) print(f"--- Project: {args.project_id} (v{ctx['version']}) ---") print(ctx["content"]) def cmd_context_edit(args): """Edit project context in $EDITOR, then auto-merge.""" cfg = CtxConfig.from_home(args.home) conn = _db.init_db(cfg) ctx = _db.context_read(conn, args.project_id) if ctx is None: print(f"Project '{args.project_id}' not found.") conn.close() sys.exit(1) # Fork workspace user_id = args.as_user or "admin" fork = _db.workspace_fork(conn, user_id, args.project_id) if not fork["ok"]: print(f"Error forking workspace: {fork.get('error')}") conn.close() sys.exit(1) ws_id = fork["workspace_id"] # Write current content to tempfile import tempfile content = ctx["content"] with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as tf: tf.write(content) temp_path = tf.name editor = os.environ.get("EDITOR", "vim") # Determine how to get output back # If EDITOR ends with 'code -w', handle that exit_code = os.system(f"{editor} {temp_path}") if exit_code != 0: print(f"Editor exited with code {exit_code}, aborting.") os.unlink(temp_path) conn.close() sys.exit(1) # Read edited content with open(temp_path) as f: new_content = f.read() os.unlink(temp_path) if new_content == content: print("No changes. Abandoning workspace.") _db.workspace_abandon(conn, ws_id) conn.commit() conn.close() return # Write back to workspace _db.workspace_write_file(conn, ws_id, new_content) conn.commit() # Submit and auto-merge (admin role auto-merges) result = _db.workspace_submit(conn, ws_id, user_id, diff_summary=f"Edited via ctx edit {args.project_id}") if result.get("ok"): if result.get("action") == "merged": print(f"✓ Context updated to v{result['new_version']}.") # Auto-sync if sync_path configured sync = _db.sync_to_project(conn, args.project_id) if sync.get("ok"): print(f" Synced to: {sync['path']}") conn.commit() else: print(f"✓ Submitted for review: {result.get('request_id')}") conn.commit() else: if result.get("error") == "conflict": print("Conflict: the shared context changed while you were editing.") print(f" Your base: v{fork['base_version']}, Current: v{result['current_version']}") print(" Re-read the current version, re-apply your edits, and try again.") else: print(f"Error: {result.get('error')}") conn.rollback() conn.close() def cmd_search(args): """Full-text search across all context.""" conn = _db.init_db(CtxConfig.from_home(args.home)) results = _db.search(conn, args.query, limit=args.limit) conn.close() if not results: print("No results.") return for r in results: src = r.get("source_type", "?") pid = r.get("project_id", "?") path = r.get("file_path", "?") snippet = (r.get("content") or "")[:200].replace("\n", " ").strip() print(f"[{src:16s}] {pid}/{path}") print(f" {snippet}...") print() def cmd_sync(args): """Sync context to project root (AGENTS.md + symlinks).""" cfg = CtxConfig.from_home(args.home) conn = _db.init_db(cfg) if args.path: # Set sync path _db.project_set_sync_path(conn, args.project_id, args.path) conn.commit() print(f"✓ Sync path set for '{args.project_id}': {args.path}") result = _db.sync_to_project(conn, args.project_id) conn.close() if result.get("ok"): print(f"✓ Synced to: {result['path']}") else: print(f"Error: {result.get('error')}") def cmd_audit(args): """Show recent audit log entries.""" conn = _db.init_db(CtxConfig.from_home(args.home)) rows = _db.audit_query(conn, limit=args.limit) conn.close() if not rows: print("No audit entries.") return print(f"{'TIMESTAMP':<22} {'USER':<16} {'AGENT':<16} {'OPERATION':<10} SUMMARY") print("-" * 110) for r in rows: print(f"{r['created_at']:<22} {r['user_id']:<16} {r['agent_id']:<16} " f"{r['operation']:<10} {(r['summary'] or '')[:50]}") def cmd_user_list(args): """List users.""" conn = _db.init_db(CtxConfig.from_home(args.home)) users = _db.user_list(conn) conn.close() if not users: print("No users.") return for u in users: print(f"{u['user_id']:<20} {u['display_name']:<25} {u['role']}") def cmd_user_create(args): """Create a new user.""" conn = _db.init_db(CtxConfig.from_home(args.home)) try: _db.user_create(conn, args.user_id, args.display_name, args.role, password=getattr(args, "password", None)) conn.commit() print(f"✓ User '{args.user_id}' created.") except Exception as e: conn.rollback() print(f"✗ {e}") sys.exit(1) finally: conn.close() def cmd_user_set_password(args): """Set or reset a user's Web UI password.""" conn = _db.init_db(CtxConfig.from_home(args.home)) try: if _db.user_get(conn, args.user_id) is None: print(f"✗ User '{args.user_id}' not found.") sys.exit(1) _db.user_set_password(conn, args.user_id, args.password) conn.commit() print(f"✓ Password set for '{args.user_id}'.") except Exception as e: conn.rollback() print(f"✗ {e}") sys.exit(1) finally: conn.close() def cmd_oauth_client_create(args): """Register an OAuth client for Claude (or other MCP connectors).""" from .server import OAuthStore, CLAUDE_MCP_REDIRECT_URI cfg = CtxConfig.from_home(args.home) store = OAuthStore(cfg) redirects = args.redirect_uri or [CLAUDE_MCP_REDIRECT_URI] if isinstance(redirects, str): redirects = [redirects] client = store.register_client({ "client_name": args.name or "Claude MCP Client", "redirect_uris": redirects, "scopes": getattr(args, "scopes", None) or ( [s for s in (args.scope or "").split() if s] or ["ctxd.read", "ctxd.write"] ), }) issuer = (cfg.oauth_issuer or "").rstrip("/") or "https://ctxd.cubecraftcreations.com" print(json.dumps({ "client_id": client["client_id"], "client_secret": client["client_secret"], "client_name": client.get("client_name"), "redirect_uris": client.get("redirect_uris"), "scope": client.get("scope"), "connector_url": f"{issuer}/mcp", "authorization_server": issuer, "note": "Claude usually registers via POST /oauth/register automatically; save client_secret now — it is not shown again.", }, indent=2)) def cmd_oauth_client_list(args): """List OAuth clients (no secrets).""" from .server import OAuthStore cfg = CtxConfig.from_home(args.home) store = OAuthStore(cfg) for c in store.list_clients_public(): print(f"{c.get('client_id')} {c.get('client_name', '')} scope={c.get('scope', '')} redirects={c.get('redirect_uris')}") def cmd_oauth_client_revoke(args): """Revoke an OAuth client and invalidate its tokens.""" from .server import OAuthStore cfg = CtxConfig.from_home(args.home) store = OAuthStore(cfg) if not store.revoke_client(args.client_id): print(f"✗ client not found: {args.client_id}") sys.exit(1) print(f"✓ Revoked OAuth client {args.client_id}") def cmd_import_vault(args): """Import context from an existing vault (e.g., OpenClawVault).""" cfg = CtxConfig.from_home(args.home) vault = Path(args.vault_path) if not vault.exists(): print(f"Vault path does not exist: {vault}") sys.exit(1) conn = _db.init_db(cfg) # Discover existing project context files in the vault known_projects = { "remote-rig": "RemoteRig", "hermes-agent": "Hermes Agent", "extrudex": "Extrudex", "lasercat": "LaserCat", "tracehound": "Tracehound", "home-assistant": "Home Assistant", "openclaw-control-center": "OpenClaw Control Center", } # Check for project directories in 02_Projects/ projects_dir = vault / "02_Projects" imported = 0 if projects_dir.exists(): for child in sorted(projects_dir.iterdir()): if not child.is_dir() and child.suffix == ".md": # Single markdown file pid = child.stem.lower().replace(" ", "-") name = child.stem content = child.read_text() _ensure_project(conn, pid, name, content) imported += 1 elif child.is_dir(): # Directory of files pid = child.name.lower().replace(" ", "-") name = child.name.replace("_", " ").replace("-", " ").title() context_file = child / "context.md" if context_file.exists(): content = context_file.read_text() _ensure_project(conn, pid, name, content) imported += 1 else: # Concatenate all markdown files parts = [] for md in sorted(child.glob("*.md")): parts.append(f"\n\n{md.read_text()}") if parts: _ensure_project(conn, pid, name, "\n\n---\n\n".join(parts)) imported += 1 # Also check 06_Knowledge for user profile knowledge_dir = vault / "06_Knowledge" if knowledge_dir: joshua_ctx = knowledge_dir / "Joshua Context.md" if joshua_ctx.exists(): content = joshua_ctx.read_text() # Set as admin profile existing = _db.profile_read(conn, "admin") if existing and existing["content"]: pass # already has profile else: _db.profile_update(conn, "admin", content, base_version=0) print(f" Imported user profile: Joshua Context.md") conn.commit() conn.close() print(f"✓ Imported {imported} project(s) from {vault}") def _ensure_project(conn, project_id, name, content): """Create project + write context if not already present.""" existing = _db.project_get(conn, project_id) if existing: print(f" Skipped (already exists): {project_id}") else: _db.project_create(conn, project_id, name) _db.context_update(conn, project_id, content, "admin", base_version=0) print(f" Imported: {project_id} ({name})") # ── CLI dispatcher ──────────────────────────────────────────────────────────── def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="dossier", description="Context Dossier — single source of truth for multi-harness project context", ) p.add_argument("--home", help="Override ~/.ctx home directory") sub = p.add_subparsers(dest="command", required=True) # init sp = sub.add_parser("init", help="Initialize ~/.ctx/ with database and seed data") sp.set_defaults(func=cmd_init) sp.add_argument("--home") # serve sp = sub.add_parser("serve", help="Start the ctxd daemon") sp.set_defaults(func=cmd_serve) sp.add_argument("--home") # project list sp = sub.add_parser("project-list", help="List projects") sp.set_defaults(func=cmd_project_list) sp.add_argument("--home") # project create sp = sub.add_parser("project-create", help="Create a new project") sp.set_defaults(func=cmd_project_create) sp.add_argument("project_id") sp.add_argument("--name", "-n", help="Display name (defaults to project_id)") sp.add_argument("--description", "-d", default="") sp.add_argument("--home") # context read sp = sub.add_parser("read", aliases=["cat"], help="Read project context") sp.set_defaults(func=cmd_context_read) sp.add_argument("project_id") sp.add_argument("--home") # context edit sp = sub.add_parser("edit", help="Edit project context in $EDITOR") sp.set_defaults(func=cmd_context_edit) sp.add_argument("project_id") sp.add_argument("--as-user", "-u", default="admin", help="Edit as this user") sp.add_argument("--home") # search sp = sub.add_parser("search", help="Full-text search across context") sp.set_defaults(func=cmd_search) sp.add_argument("query") sp.add_argument("--limit", "-l", type=int, default=10) sp.add_argument("--home") # sync sp = sub.add_parser("sync", help="Sync context to project AGENTS.md") sp.set_defaults(func=cmd_sync) sp.add_argument("project_id") sp.add_argument("path", nargs="?", help="Set sync path (absolute)") sp.add_argument("--home") # audit sp = sub.add_parser("audit", help="Show recent audit log") sp.set_defaults(func=cmd_audit) sp.add_argument("--limit", "-l", type=int, default=20) sp.add_argument("--home") # user list sp = sub.add_parser("user-list", help="List users") sp.set_defaults(func=cmd_user_list) sp.add_argument("--home") # user create sp = sub.add_parser("user-create", help="Create a new user") sp.set_defaults(func=cmd_user_create) sp.add_argument("user_id") sp.add_argument("--display-name", "-n", required=True) sp.add_argument("--role", "-r", default="contributor", choices=["admin", "contributor", "service"]) sp.add_argument("--password", help="Optional Web UI login password") sp.add_argument("--home") # user set-password sp = sub.add_parser("user-set-password", help="Set a user's Web UI password") sp.set_defaults(func=cmd_user_set_password) sp.add_argument("user_id") sp.add_argument("--password", "-p", required=True) sp.add_argument("--home") # oauth-client-create sp = sub.add_parser("oauth-client-create", help="Register OAuth client (Claude MCP); prints client_id and client_secret") sp.set_defaults(func=cmd_oauth_client_create) sp.add_argument("--name", "-n", default="Claude MCP Client", help="Client display name") sp.add_argument("--redirect-uri", action="append", dest="redirect_uri", help="Redirect URI (default: Claude MCP callback; repeat for multiple)") sp.add_argument("--scope", default="ctxd.read ctxd.write", help="Allowed scopes (space-separated: ctxd.read ctxd.write)") sp.add_argument("--home") # oauth-client-list sp = sub.add_parser("oauth-client-list", help="List OAuth clients (no secrets)") sp.set_defaults(func=cmd_oauth_client_list) sp.add_argument("--home") # oauth-client-revoke sp = sub.add_parser("oauth-client-revoke", help="Revoke OAuth client and invalidate its tokens") sp.set_defaults(func=cmd_oauth_client_revoke) sp.add_argument("client_id", help="client_id to revoke (ctxd_…)") sp.add_argument("--home") # import-vault sp = sub.add_parser("import-vault", help="Import context from Obsidian vault") sp.set_defaults(func=cmd_import_vault) sp.add_argument("vault_path", help="Path to vault directory (e.g., ~/OpenClawVault)") sp.add_argument("--home") return p def cli_entry(): parser = build_parser() args = parser.parse_args() if hasattr(args, "func"): args.func(args) else: parser.print_help() if __name__ == "__main__": cli_entry()