2026-06-23 23:54:37 +00:00
|
|
|
"""
|
|
|
|
|
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."""
|
2026-06-25 00:20:55 +00:00
|
|
|
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))
|
2026-06-23 23:54:37 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-06-24 22:50:54 +00:00
|
|
|
_db.user_create(conn, args.user_id, args.display_name, args.role, password=getattr(args, "password", None))
|
2026-06-23 23:54:37 +00:00
|
|
|
conn.commit()
|
|
|
|
|
print(f"✓ User '{args.user_id}' created.")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
conn.rollback()
|
2026-06-24 22:50:54 +00:00
|
|
|
print(f"✗ {e}")
|
|
|
|
|
sys.exit(1)
|
2026-06-23 23:54:37 +00:00
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 22:50:54 +00:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
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"),
|
|
|
|
|
"connector_url": f"{issuer}/readonly/sse",
|
|
|
|
|
"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', '')} 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}")
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 23:54:37 +00:00
|
|
|
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"])
|
2026-06-24 22:50:54 +00:00
|
|
|
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("--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_…)")
|
2026-06-23 23:54:37 +00:00
|
|
|
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()
|