Add custodian CLI — register-project and status subcommands

custodian register-project [--domain DOMAIN] [--path PATH]
  Defaults path to cwd; auto-detects domain from project charter if
  --domain is omitted. Does: API health → topic lookup → MCP check →
  CLAUDE.md from template → progress event.

custodian status
  Prints API health + summary totals + blocking decisions.

Installed via: make install-cli (symlinks .venv/bin/custodian → ~/.local/bin/)
Entry point declared in pyproject.toml [project.scripts].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 22:55:41 +01:00
parent b93df3aa68
commit ae10aaf33f
3 changed files with 230 additions and 1 deletions

View File

@@ -1,10 +1,18 @@
.PHONY: install db db-tools migrate seed api dashboard check start clean register-project
.PHONY: install install-cli db db-tools migrate seed api dashboard check start clean register-project
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
install:
uv sync
## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide
install-cli: install
mkdir -p ~/.local/bin
ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian
@echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)"
@echo "Make sure ~/.local/bin is on your PATH:"
@echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc"
db:
$(COMPOSE) up -d postgres

217
state-hub/custodian_cli.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
custodian — CLI for the Custodian State Hub.
Usage:
custodian register-project [--domain DOMAIN] [--path PATH]
Run from inside the project directory you want to connect.
--domain defaults to auto-detection from the project charter.
--path defaults to current working directory.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
STATE_HUB_DIR = Path(__file__).resolve().parent
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template"
PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py"
VALID_DOMAINS = [
"custodian", "railiance", "markitect",
"coulomb_social", "personhood", "foerster_capabilities",
]
# ── Helpers ────────────────────────────────────────────────────────────────────
def _api_get(path: str) -> object:
url = API_BASE.rstrip("/") + path
try:
with urllib.request.urlopen(url, timeout=10) as r:
return json.loads(r.read())
except urllib.error.URLError as e:
print(f"ERROR: Cannot reach API at {API_BASE}: {e}")
print(f" Start it: cd {STATE_HUB_DIR} && make api")
sys.exit(1)
def _api_post(path: str, body: dict) -> object:
url = API_BASE.rstrip("/") + path
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read())
def _detect_domain(project_path: Path) -> str | None:
"""Try to read domain from project charter frontmatter."""
for charter in project_path.rglob("project_charter_v*.md"):
text = charter.read_text()
m = re.search(r"^domain:\s*(\S+)", text, re.MULTILINE)
if m:
return m.group(1).strip('"\'')
return None
def _check_mcp() -> bool:
claude_json = Path.home() / ".claude.json"
if not claude_json.exists():
return False
config = json.loads(claude_json.read_text())
return "state-hub" in config.get("mcpServers", {})
# ── Subcommands ────────────────────────────────────────────────────────────────
def cmd_register(args: argparse.Namespace) -> None:
project_path = Path(args.path).resolve()
if not project_path.is_dir():
print(f"ERROR: {project_path} is not a directory.")
sys.exit(1)
project_name = project_path.name
# ── Step 1: API health ─────────────────────────────────────────────────────
print(f"==> Checking API at {API_BASE} ...")
_api_get("/state/health")
print(" API OK")
# ── Step 2: Domain ─────────────────────────────────────────────────────────
domain = args.domain
if not domain:
print("==> Auto-detecting domain from project charter ...")
domain = _detect_domain(project_path)
if domain:
print(f" Detected: {domain}")
else:
print(f"ERROR: Could not auto-detect domain. Pass --domain explicitly.")
print(f" Valid: {', '.join(VALID_DOMAINS)}")
sys.exit(1)
if domain not in VALID_DOMAINS:
print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(VALID_DOMAINS)}")
sys.exit(1)
# ── Step 3: Topic ID lookup ────────────────────────────────────────────────
print(f"==> Looking up topic for domain '{domain}' ...")
topics = _api_get("/topics/?status=active")
match = next((t for t in topics if t.get("domain") == domain), None)
if not match:
print(f"ERROR: No active topic found for domain '{domain}'.")
sys.exit(1)
topic_id = match["id"]
print(f" topic_id: {topic_id}")
# ── Step 4: MCP check ──────────────────────────────────────────────────────
print("==> Checking MCP server registration ...")
if _check_mcp():
print(" MCP OK")
else:
print("WARNING: 'state-hub' not in ~/.claude.json.")
print(f" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
# ── Step 5: CLAUDE.md ──────────────────────────────────────────────────────
claude_md = project_path / "CLAUDE.md"
if claude_md.exists():
print(f"==> CLAUDE.md already exists at {claude_md} — skipping.")
else:
print(f"==> Writing CLAUDE.md to {claude_md} ...")
content = TEMPLATE.read_text()
content = content.replace("{PROJECT_NAME}", project_name)
content = content.replace("{DOMAIN}", domain)
content = content.replace("{TOPIC_ID}", topic_id)
claude_md.write_text(content)
print(" Written.")
# ── Step 6: Progress event ─────────────────────────────────────────────────
print("==> Recording registration event ...")
try:
_api_post("/progress/", {
"topic_id": topic_id,
"event_type": "milestone",
"summary": f"Project registered with State Hub: {project_name} ({domain})",
"author": "custodian",
"detail": {
"project_path": str(project_path),
"claude_md": str(claude_md),
"domain": domain,
},
})
print(" Event recorded.")
except Exception as e:
print(f" WARNING: Could not record progress event: {e}")
print()
print("Registration complete!")
print(f" Project: {project_name}")
print(f" Domain: {domain}")
print(f" Topic ID: {topic_id}")
print(f" CLAUDE.md: {claude_md}")
print()
print("Next: restart Claude Code for the MCP server to be active in this project.")
def cmd_status(_args: argparse.Namespace) -> None:
"""Quick status: API health + summary totals."""
health = _api_get("/state/health")
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
summary = _api_get("/state/summary")
t = summary["totals"]
print(f"Topics: {t['topics']['active']} active")
print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked")
print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked")
print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated")
blocking = summary.get("blocking_decisions", [])
if blocking:
print(f"\nBlocking decisions ({len(blocking)}):")
for d in blocking:
deadline = d.get("deadline") or "no deadline"
print(f" [{deadline}] {d['title']}")
# ── Entry point ────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
prog="custodian",
description="Custodian State Hub CLI",
)
sub = parser.add_subparsers(dest="command", required=True)
# register-project
reg = sub.add_parser("register-project", help="Register a project with the State Hub")
reg.add_argument(
"--domain",
choices=VALID_DOMAINS,
default=None,
help="Project domain (auto-detected from charter if omitted)",
)
reg.add_argument(
"--path",
default=os.getcwd(),
help="Project directory (defaults to current directory)",
)
# status
sub.add_parser("status", help="Show State Hub health and summary totals")
args = parser.parse_args()
if args.command == "register-project":
cmd_register(args)
elif args.command == "status":
cmd_status(args)
if __name__ == "__main__":
main()

View File

@@ -17,12 +17,16 @@ dependencies = [
"psycopg2-binary>=2.9.0",
]
[project.scripts]
custodian = "custodian_cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["api", "mcp_server"]
artifacts = ["custodian_cli.py"]
[tool.uv]
dev-dependencies = [