Files
CTXD/app/src/ctxd/cli.py
T
overseer 1c9d8f7648 feat: OAuth client scope assignment in admin panel
- Create client: ctxd.read / ctxd.write checkboxes
- Client list: show scopes, edit via PATCH /oauth/clients/:id
- Authorize grants intersection of client allowed scopes and request
- CLI oauth-client-create --scope; DCR default ctxd.read ctxd.write
2026-06-25 13:13:25 +00:00

600 lines
20 KiB
Python

"""
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 <proj>` | Edit project context |
| `ctx read <proj>` | Read project context |
| `ctx sync <proj> [path]` | Sync to project root |
| `ctx search <query>` | 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 <project>")
print(" 2. Start the daemon: ctx serve")
print(" 3. Set sync path: ctx sync <project> <path>")
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"<!-- from {md.name} -->\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()