Files
Constant/constant/cli.py
T
2026-04-05 18:29:48 +02:00

613 lines
23 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
from . import __version__
from .daemon import daemon_status, request as daemon_request, serve_foreground, start_background, stop_background
from .executors import bridge_sync, execute_step, fleet_check
from .memory import (
enroll_workspace,
list_decisions,
memory_status,
persona_markdown,
rebuild_workspace_memory,
search_memory,
summarize_mission,
sync_qdrant,
)
from .paths import cache_root, repo_root, scripts_dir
from .state import (
append_event,
create_mission,
first_active_step,
fleet_machine,
load_fleet_config,
load_mission,
load_models_config,
list_missions,
mission_events_file,
save_mission,
write_artifact,
)
from .tui import run_tui
def _print(data: Any, as_json: bool = False) -> None:
if as_json:
print(json.dumps(data, indent=2, sort_keys=True))
elif isinstance(data, str):
print(data)
else:
print(json.dumps(data, indent=2, sort_keys=True))
def _command_exists(binary: str) -> bool:
for path_dir in os.environ.get("PATH", "").split(":"):
candidate = Path(path_dir) / binary
if candidate.exists() and os.access(candidate, os.X_OK):
return True
return False
def _warm_memory(workspace: str) -> dict[str, Any] | None:
try:
return rebuild_workspace_memory(workspace, enroll=True)
except Exception as exc: # noqa: BLE001
return {"error": str(exc), "workspace": workspace}
def _finalize_mission_memory(mission_id: str) -> dict[str, Any] | None:
try:
return summarize_mission(mission_id)
except Exception: # noqa: BLE001
return None
def _ensure_planned(mission: dict[str, Any]) -> dict[str, Any]:
if mission["steps"]:
return mission
payload = daemon_request("plan", {"mission": mission})
mission["steps"] = payload["plan"]["steps"]
mission["title"] = payload["plan"]["title"]
mission["status"] = "planned"
mission["planner_summary"] = payload["plan"]["summary"]
mission["buddy_review"] = payload["buddy_review"]
save_mission(mission)
append_event(mission["mission_id"], "mission.planned", payload)
return mission
def _mission_summary(mission: dict[str, Any]) -> dict[str, Any]:
return {
"mission_id": mission["mission_id"],
"title": mission["title"],
"status": mission["status"],
"workspace": mission["workspace"],
"steps": [
{
"step_id": step["step_id"],
"status": step["status"],
"machine": step["machine"],
"backend": step["backend"],
"cli": step["cli"],
"agent": step["agent"],
"attempt": step["attempt"],
}
for step in mission["steps"]
],
}
def cmd_doctor(args: argparse.Namespace) -> int:
status = daemon_status()
health = daemon_request("health", auto_start=False)
fleet = load_fleet_config()
models = load_models_config()
report = {
"version": __version__,
"repo_root": str(repo_root()),
"cache_root": str(cache_root()),
"commands": {
"python3": _command_exists("python3"),
"tmux": _command_exists("tmux"),
"omc": _command_exists("omc"),
"claude": _command_exists("claude"),
"codex": _command_exists("codex"),
"copilot": _command_exists("copilot"),
"vibe": _command_exists("vibe"),
"constant-fleet": (scripts_dir() / "constant-fleet.sh").exists() or (scripts_dir() / "zellij-ai-fleet.sh").exists(),
"ai-bridge": (scripts_dir() / "ai-bridge.sh").exists(),
},
"daemon": status,
"models": models,
"health": health,
"memory": memory_status(),
"fleet": [{"label": entry["label"], "target": entry["target"]} for entry in fleet["machines"]],
}
_print(report, args.json)
return 0
def cmd_daemon_start(_: argparse.Namespace) -> int:
payload = start_background()
if not payload.get("running"):
payload["mode"] = "inline-fallback"
_print(payload)
return 0
def cmd_daemon_stop(_: argparse.Namespace) -> int:
_print(stop_background())
return 0
def cmd_daemon_status(args: argparse.Namespace) -> int:
payload = {"status": daemon_status(), "health": daemon_request("health", auto_start=False)}
if not payload["status"]["running"]:
payload["mode"] = "inline-fallback"
_print(payload, args.json)
return 0
def cmd_daemon_logs(_: argparse.Namespace) -> int:
from .paths import daemon_log_path
path = daemon_log_path()
if not path.exists():
print("No daemon log yet.")
return 0
print(path.read_text(encoding="utf-8"))
return 0
def cmd_models_status(args: argparse.Namespace) -> int:
payload = {"config": load_models_config(), "daemon": daemon_status(), "health": daemon_request("health", auto_start=False)}
if not payload["daemon"]["running"]:
payload["mode"] = "inline-fallback"
_print(payload, args.json)
return 0
def cmd_fleet_status(args: argparse.Namespace) -> int:
payload = fleet_check()
_print(payload, args.json)
return 0 if payload["returncode"] == 0 else 1
def cmd_fleet_sync(args: argparse.Namespace) -> int:
payload = bridge_sync()
_print(payload, args.json)
return 0 if payload["returncode"] == 0 else 1
def cmd_cockpit_open(args: argparse.Namespace) -> int:
workspace = args.workspace or os.getcwd()
command = [
str((scripts_dir() / "constant-fleet.sh") if (scripts_dir() / "constant-fleet.sh").exists() else (scripts_dir() / "zellij-ai-fleet.sh")),
"--workspace",
workspace,
"--local-session",
args.local_session or "constant-fleet",
"--session",
args.session or "constant",
]
os.execv(command[0], command)
return 0
def cmd_tui(args: argparse.Namespace) -> int:
workspace = str(Path(args.workspace or os.getcwd()).expanduser().resolve())
action = run_tui(workspace)
if action and action.get("action") == "cockpit":
return cmd_cockpit_open(
argparse.Namespace(
workspace=action.get("workspace", workspace),
local_session=args.local_session,
session=args.session,
)
)
return 0
def cmd_mission_create(args: argparse.Namespace) -> int:
_warm_memory(args.workspace)
mission = create_mission(args.prompt, args.workspace)
mission = _ensure_planned(mission)
_print(_mission_summary(mission), args.json)
return 0
def cmd_mission_plan(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id)
_warm_memory(mission["workspace"])
payload = daemon_request("plan", {"mission": mission})
mission["steps"] = payload["plan"]["steps"]
mission["title"] = payload["plan"]["title"]
mission["status"] = "planned"
mission["planner_summary"] = payload["plan"]["summary"]
mission["buddy_review"] = payload["buddy_review"]
save_mission(mission)
append_event(mission["mission_id"], "mission.replanned", payload)
_print({"summary": _mission_summary(mission), "buddy_review": payload["buddy_review"]}, args.json)
return 0
def _verify_and_update(mission: dict[str, Any], step: dict[str, Any], execution: dict[str, Any]) -> dict[str, Any]:
verdict = daemon_request("verify", {"mission": mission, "step": step, "execution": execution})
step["verification"] = verdict
step["result_summary"] = verdict.get("summary", "")
return verdict
def cmd_mission_run(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id)
mission = _ensure_planned(mission)
step = first_active_step(mission)
if step is None:
mission["status"] = "done"
save_mission(mission)
_print(_mission_summary(mission), args.json)
return 0
while step is not None:
step["status"] = "running"
step["attempt"] += 1
mission["status"] = "running"
save_mission(mission)
append_event(mission["mission_id"], "step.started", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"]})
execution = execute_step(step, mission, load_fleet_config())
artifact_path = write_artifact(
mission["mission_id"],
f"{step['step_id']}-attempt-{step['attempt']}.json",
execution,
)
step["artifact_refs"].append(artifact_path)
append_event(mission["mission_id"], "step.executed", {"step_id": step["step_id"], "artifact": artifact_path, "returncode": execution["returncode"]})
verdict = _verify_and_update(mission, step, execution)
decision = verdict.get("decision", "failed")
if decision == "done":
step["status"] = "done"
elif decision == "retry" and step["attempt"] < 2:
step["status"] = "pending"
elif decision == "needs_human":
step["status"] = "needs_human"
mission["status"] = "needs_human"
save_mission(mission)
append_event(mission["mission_id"], "step.needs_human", {"step_id": step["step_id"], "summary": verdict.get("summary", "")})
_finalize_mission_memory(mission["mission_id"])
_print({"summary": _mission_summary(mission), "verdict": verdict}, args.json)
return 1
else:
step["status"] = "failed"
mission["status"] = "failed"
save_mission(mission)
append_event(mission["mission_id"], "step.failed", {"step_id": step["step_id"], "summary": verdict.get("summary", "")})
_finalize_mission_memory(mission["mission_id"])
_print({"summary": _mission_summary(mission), "verdict": verdict}, args.json)
return 1
save_mission(mission)
append_event(mission["mission_id"], "step.verified", {"step_id": step["step_id"], "verdict": verdict})
step = first_active_step(mission)
mission["status"] = "done"
save_mission(mission)
append_event(mission["mission_id"], "mission.done", {"mission_id": mission["mission_id"]})
_finalize_mission_memory(mission["mission_id"])
_print(_mission_summary(mission), args.json)
return 0
def cmd_mission_status(args: argparse.Namespace) -> int:
if args.mission_id:
mission = load_mission(args.mission_id)
_print(_mission_summary(mission) if not args.verbose else mission, args.json)
return 0
missions = [_mission_summary(mission) for mission in list_missions()]
_print({"missions": missions}, args.json)
return 0
def cmd_mission_tail(args: argparse.Namespace) -> int:
path = mission_events_file(args.mission_id)
if not path.exists():
print(f"No events for mission {args.mission_id}")
return 1
if not args.follow:
print(path.read_text(encoding="utf-8"), end="")
return 0
seen_size = 0
while True:
text = path.read_text(encoding="utf-8")
if len(text) > seen_size:
print(text[seen_size:], end="")
seen_size = len(text)
time.sleep(1)
def cmd_mission_verify(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id)
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else (mission["steps"][-1] if mission["steps"] else None)
if step is None or not step["artifact_refs"]:
print("No step artifact available to verify.")
return 1
artifact = json.loads(Path(step["artifact_refs"][-1]).read_text(encoding="utf-8"))
verdict = _verify_and_update(mission, step, artifact)
save_mission(mission)
append_event(mission["mission_id"], "mission.verify", {"step_id": step["step_id"], "verdict": verdict})
_print({"step_id": step["step_id"], "verdict": verdict}, args.json)
return 0 if verdict.get("decision") == "done" else 1
def cmd_mission_retry(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id)
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission) or (mission["steps"][-1] if mission["steps"] else None)
if step is None:
print("No step to retry.")
return 1
step["status"] = "pending"
mission["status"] = "planned"
save_mission(mission)
append_event(mission["mission_id"], "step.retry_requested", {"step_id": step["step_id"]})
_print(_mission_summary(mission), args.json)
return 0
def cmd_delegate(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id)
step = next((entry for entry in mission["steps"] if entry["step_id"] == args.step_id), None) if args.step_id else first_active_step(mission)
if step is None:
print("No active step to delegate.")
return 1
if args.machine:
fleet_machine(args.machine)
step["machine"] = args.machine
if args.backend:
step["backend"] = args.backend
if args.cli:
step["cli"] = args.cli
if args.agent:
step["agent"] = args.agent
step["status"] = "pending"
mission["status"] = "planned"
save_mission(mission)
append_event(mission["mission_id"], "step.delegated", {"step_id": step["step_id"], "machine": step["machine"], "backend": step["backend"], "cli": step["cli"], "agent": step["agent"]})
_print(_mission_summary(mission), args.json)
return 0
def cmd_buddy_ask(args: argparse.Namespace) -> int:
mission = load_mission(args.mission_id) if args.mission_id else None
if mission:
_warm_memory(mission["workspace"])
answer = daemon_request("buddy", {"mission": mission, "prompt": args.prompt})
if mission:
append_event(mission["mission_id"], "buddy.ask", {"prompt": args.prompt, "answer": answer})
_print(answer, args.json)
return 0
def cmd_memory_status(args: argparse.Namespace) -> int:
_print(memory_status(args.workspace), args.json)
return 0
def cmd_memory_rebuild(args: argparse.Namespace) -> int:
_print(rebuild_workspace_memory(args.workspace, enroll=not args.no_enroll), args.json)
return 0
def cmd_memory_enroll(args: argparse.Namespace) -> int:
_print(enroll_workspace(args.path), args.json)
return 0
def cmd_memory_search(args: argparse.Namespace) -> int:
_print(search_memory(args.query, args.workspace, args.limit), args.json)
return 0
def cmd_memory_persona_show(args: argparse.Namespace) -> int:
payload = {"persona": persona_markdown()} if args.json else persona_markdown()
_print(payload, args.json)
return 0
def cmd_memory_decisions(args: argparse.Namespace) -> int:
_print(list_decisions(args.workspace, args.mission_id), args.json)
return 0
def cmd_memory_sync_qdrant(args: argparse.Namespace) -> int:
payload = sync_qdrant(args.workspace)
_print(payload, args.json)
return 0 if payload.get("ok") or payload.get("skipped") else 1
def cmd_mission_summarize(args: argparse.Namespace) -> int:
_print(summarize_mission(args.mission_id), args.json)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="Constant")
parser.add_argument("-V", "--version", action="version", version=__version__)
subparsers = parser.add_subparsers(dest="command", required=True)
doctor = subparsers.add_parser("doctor")
doctor.add_argument("--json", action="store_true")
doctor.set_defaults(func=cmd_doctor)
tui = subparsers.add_parser("tui")
tui.add_argument("--workspace")
tui.add_argument("--local-session")
tui.add_argument("--session")
tui.set_defaults(func=cmd_tui)
daemon = subparsers.add_parser("daemon")
daemon_sub = daemon.add_subparsers(dest="daemon_command", required=True)
daemon_start = daemon_sub.add_parser("start")
daemon_start.set_defaults(func=cmd_daemon_start)
daemon_stop = daemon_sub.add_parser("stop")
daemon_stop.set_defaults(func=cmd_daemon_stop)
daemon_status_cmd = daemon_sub.add_parser("status")
daemon_status_cmd.add_argument("--json", action="store_true")
daemon_status_cmd.set_defaults(func=cmd_daemon_status)
daemon_logs = daemon_sub.add_parser("logs")
daemon_logs.set_defaults(func=cmd_daemon_logs)
models = subparsers.add_parser("models")
models_sub = models.add_subparsers(dest="models_command", required=True)
models_status = models_sub.add_parser("status")
models_status.add_argument("--json", action="store_true")
models_status.set_defaults(func=cmd_models_status)
fleet = subparsers.add_parser("fleet")
fleet_sub = fleet.add_subparsers(dest="fleet_command", required=True)
fleet_status_cmd = fleet_sub.add_parser("status")
fleet_status_cmd.add_argument("--json", action="store_true")
fleet_status_cmd.set_defaults(func=cmd_fleet_status)
fleet_sync_cmd = fleet_sub.add_parser("sync")
fleet_sync_cmd.add_argument("--json", action="store_true")
fleet_sync_cmd.set_defaults(func=cmd_fleet_sync)
cockpit = subparsers.add_parser("cockpit")
cockpit_sub = cockpit.add_subparsers(dest="cockpit_command", required=True)
cockpit_open = cockpit_sub.add_parser("open")
cockpit_open.add_argument("--workspace")
cockpit_open.add_argument("--local-session")
cockpit_open.add_argument("--session")
cockpit_open.set_defaults(func=cmd_cockpit_open)
mission = subparsers.add_parser("mission")
mission_sub = mission.add_subparsers(dest="mission_command", required=True)
mission_create = mission_sub.add_parser("create")
mission_create.add_argument("prompt")
mission_create.add_argument("--workspace", default=os.getcwd())
mission_create.add_argument("--json", action="store_true")
mission_create.set_defaults(func=cmd_mission_create)
mission_plan = mission_sub.add_parser("plan")
mission_plan.add_argument("mission_id")
mission_plan.add_argument("--json", action="store_true")
mission_plan.set_defaults(func=cmd_mission_plan)
mission_run = mission_sub.add_parser("run")
mission_run.add_argument("mission_id")
mission_run.add_argument("--json", action="store_true")
mission_run.set_defaults(func=cmd_mission_run)
mission_status = mission_sub.add_parser("status")
mission_status.add_argument("mission_id", nargs="?")
mission_status.add_argument("--verbose", action="store_true")
mission_status.add_argument("--json", action="store_true")
mission_status.set_defaults(func=cmd_mission_status)
mission_tail = mission_sub.add_parser("tail")
mission_tail.add_argument("mission_id")
mission_tail.add_argument("--follow", action="store_true")
mission_tail.set_defaults(func=cmd_mission_tail)
mission_verify = mission_sub.add_parser("verify")
mission_verify.add_argument("mission_id")
mission_verify.add_argument("--step-id")
mission_verify.add_argument("--json", action="store_true")
mission_verify.set_defaults(func=cmd_mission_verify)
mission_retry = mission_sub.add_parser("retry")
mission_retry.add_argument("mission_id")
mission_retry.add_argument("--step-id")
mission_retry.add_argument("--json", action="store_true")
mission_retry.set_defaults(func=cmd_mission_retry)
mission_summarize = mission_sub.add_parser("summarize")
mission_summarize.add_argument("mission_id")
mission_summarize.add_argument("--json", action="store_true")
mission_summarize.set_defaults(func=cmd_mission_summarize)
delegate = subparsers.add_parser("delegate")
delegate.add_argument("mission_id")
delegate.add_argument("--step-id")
delegate.add_argument("--machine")
delegate.add_argument("--backend")
delegate.add_argument("--cli")
delegate.add_argument("--agent")
delegate.add_argument("--json", action="store_true")
delegate.set_defaults(func=cmd_delegate)
buddy = subparsers.add_parser("buddy")
buddy_sub = buddy.add_subparsers(dest="buddy_command", required=True)
buddy_ask = buddy_sub.add_parser("ask")
buddy_ask.add_argument("prompt")
buddy_ask.add_argument("--mission-id")
buddy_ask.add_argument("--json", action="store_true")
buddy_ask.set_defaults(func=cmd_buddy_ask)
memory = subparsers.add_parser("memory")
memory_sub = memory.add_subparsers(dest="memory_command", required=True)
memory_status_cmd = memory_sub.add_parser("status")
memory_status_cmd.add_argument("--workspace")
memory_status_cmd.add_argument("--json", action="store_true")
memory_status_cmd.set_defaults(func=cmd_memory_status)
memory_rebuild_cmd = memory_sub.add_parser("rebuild")
memory_rebuild_cmd.add_argument("--workspace", default=os.getcwd())
memory_rebuild_cmd.add_argument("--no-enroll", action="store_true")
memory_rebuild_cmd.add_argument("--json", action="store_true")
memory_rebuild_cmd.set_defaults(func=cmd_memory_rebuild)
memory_enroll_cmd = memory_sub.add_parser("enroll")
memory_enroll_cmd.add_argument("path")
memory_enroll_cmd.add_argument("--json", action="store_true")
memory_enroll_cmd.set_defaults(func=cmd_memory_enroll)
memory_search_cmd = memory_sub.add_parser("search")
memory_search_cmd.add_argument("query")
memory_search_cmd.add_argument("--workspace")
memory_search_cmd.add_argument("--limit", type=int)
memory_search_cmd.add_argument("--json", action="store_true")
memory_search_cmd.set_defaults(func=cmd_memory_search)
memory_persona_cmd = memory_sub.add_parser("persona")
memory_persona_sub = memory_persona_cmd.add_subparsers(dest="memory_persona_command", required=True)
memory_persona_show_cmd = memory_persona_sub.add_parser("show")
memory_persona_show_cmd.add_argument("--json", action="store_true")
memory_persona_show_cmd.set_defaults(func=cmd_memory_persona_show)
memory_decisions_cmd = memory_sub.add_parser("decisions")
memory_decisions_cmd.add_argument("--workspace")
memory_decisions_cmd.add_argument("--mission-id")
memory_decisions_cmd.add_argument("--json", action="store_true")
memory_decisions_cmd.set_defaults(func=cmd_memory_decisions)
memory_sync_cmd = memory_sub.add_parser("sync-qdrant")
memory_sync_cmd.add_argument("--workspace")
memory_sync_cmd.add_argument("--json", action="store_true")
memory_sync_cmd.set_defaults(func=cmd_memory_sync_qdrant)
hidden = subparsers.add_parser("__serve")
hidden.set_defaults(func=lambda _args: serve_foreground())
return parser
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if not argv:
if sys.stdin.isatty() and sys.stdout.isatty():
argv = ["tui", "--workspace", os.getcwd()]
else:
argv = ["doctor"]
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())