File size: 15,965 Bytes
587a933 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 | #!/usr/bin/env python3
"""
kanbot β the unified CLI + agent harness for the board-that-is-the-runtime.
One entrypoint, subcommands, provider-agnostic. Humans read the pretty output;
agents pass --json and get one machine object per command. Every mutating verb
maps 1:1 to a KanBot MCP tool; the LLM backend is swappable via the provider
harness (ollama|openai|anthropic|openrouter|vllm|openai_compat|mock).
kanbot board [--json] [--demo] show the board
kanbot tui [--demo] launch the interactive curses board
kanbot agent [--demo] the JSONL line-protocol loop (agent-driven)
kanbot card <id> [--json] card detail
kanbot move <id> <col> [-m reason] guarded transition (move_card)
kanbot claim <id> <worker> claim a ready card (claim_card)
kanbot approve <id> unblock a proposal (-> ready)
kanbot block <id> <reason> halt a card (block_card)
kanbot human <id> <question> flag for a human (request_human)
kanbot new "<title>" [-s spec] [-c crit ...] create a card (create_card)
kanbot run [--dry-run|--live] [--swarm N] the autonomous conductor loop
kanbot providers [--json] list providers + active + resolved models
kanbot doctor [--json] preflight: DB, provider, models, dry-run
kanbot config [--json] resolved config (env > default), keys masked
Exit codes: 0 ok Β· 1 operational failure Β· 2 usage error.
Config precedence: CLI flag > environment > built-in default. Secrets never printed.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
# --- import the KanBot package whether invoked as `kanbot`, `-m cli.kanbot_cli`,
# or from inside cli/ -------------------------------------------------------
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
GREEN, RED, YEL = "\033[32m", "\033[31m", "\033[33m"
def _out(obj: dict, as_json: bool, human: str | None = None) -> None:
if as_json:
print(json.dumps(obj))
else:
print(human if human is not None else json.dumps(obj, indent=2))
# --------------------------------------------------------------------------- #
# board / tui / agent β delegate to the existing dual-mode cli/tui.py
# --------------------------------------------------------------------------- #
def _board(demo: bool):
from cli.tui import Board
return Board(demo=demo)
async def cmd_board(a) -> int:
snap = await _board(a.demo).snapshot()
if a.json:
_out({"ok": True, **snap}, True)
return 0
cols = snap["columns"]
print(f"{BOLD} KANBOT board {RESET} " + DIM +
" ".join(f"{c}:{cols[c]}" for c in cols) + RESET)
for col in cols:
cards = [c for c in snap["cards"] if c["column"] == col]
if not cards:
continue
print(f"\n{BOLD}{col.upper()}{RESET}")
for c in cards:
att = f"a:{c['attempt']}" + ("/3" if c["attempt"] else "")
flag = f" {YEL}β{RESET}" if c.get("requires_human") else ""
print(f" {c['id']:<8} {c['title'][:52]:<52} "
f"[{c['worker'] or 'β':<11}] {att} {c.get('lease','β')}{flag}")
return 0
def cmd_tui(a) -> int:
from cli.tui import Board, run_interactive
return run_interactive(Board(demo=a.demo))
def cmd_agent(a) -> int:
from cli.tui import Board, run_agent
return asyncio.run(run_agent(Board(demo=a.demo)))
# --------------------------------------------------------------------------- #
# card actions β each maps to one MCP tool via the Board wrapper
# --------------------------------------------------------------------------- #
async def cmd_card(a) -> int:
res = await _board(a.demo).get(a.id)
_out(res, a.json, None if a.json else json.dumps(res.get("card", res), indent=2))
return 0 if res.get("ok") else 1
async def _act(a, coro) -> int:
res = await coro
ok = res.get("ok", False)
if a.json:
_out(res, True)
else:
mark = f"{GREEN}β{RESET}" if ok else f"{RED}β{RESET}"
detail = res.get("to") or res.get("error") or res.get("note") or res.get("tool", "")
print(f"{mark} {res.get('tool','action')}: {detail}")
return 0 if ok else 1
async def cmd_move(a): return await _act(a, _board(a.demo).move(a.id, a.column, a.reason or "moved via cli"))
async def cmd_claim(a): return await _act(a, _board(a.demo).claim(a.id, a.worker))
async def cmd_approve(a): return await _act(a, _board(a.demo).approve(a.id))
async def cmd_block(a): return await _act(a, _board(a.demo).block(a.id, a.reason))
async def cmd_human(a): return await _act(a, _board(a.demo).human(a.id, a.question))
async def cmd_new(a) -> int:
b = _board(a.demo)
if b.demo:
return await _act(a, b.new(a.title, a.spec or ""))
from core import mcp_server
raw = json.loads(await mcp_server.create_card(
title=a.title, spec=a.spec or "", acceptance_criteria=a.criteria or []))
res = {"ok": "error" not in raw, "tool": "create_card", **raw}
return await _act(a, _coro_ret(res))
async def _coro_ret(v): # tiny helper so _act can await a plain dict
return v
# --------------------------------------------------------------------------- #
# providers / doctor / config β the harness surface
# --------------------------------------------------------------------------- #
def cmd_providers(a) -> int:
from core import providers
d = providers.describe()
if a.json:
_out({"ok": True, **d}, True); return 0
print(f"{BOLD}Active provider:{RESET} {d['active']}")
print(f"{DIM}Supported:{RESET} {', '.join(d['supported'])}")
print(f"{BOLD}Models{RESET} fast={d['models']['fast']} "
f"daily={d['models']['daily']} heavy={d['models']['heavy']}")
if d["key_required"]:
state = f"{GREEN}present{RESET}" if d["key_present"] else f"{RED}MISSING{RESET}"
print(f"{BOLD}Key{RESET} {d['key_required']}: {state}")
if d["base_url"]:
print(f"{BOLD}Base URL{RESET} {d['base_url']}")
return 0
async def cmd_doctor(a) -> int:
"""No-mock preflight: probe the REAL services the active config needs."""
from core import providers
checks, fail = [], 0
# provider reachability β mock is always green; real ones get a tiny ping
d = providers.describe()
prov = d["active"]
if prov == "mock":
checks.append(("provider", True, "mock (hermetic, always ready)"))
elif prov == "ollama":
ok, msg = await _ping_ollama()
checks.append(("provider", ok, msg)); fail += not ok
else:
need = d["key_required"]
ok = (not need) or d["key_present"]
checks.append(("provider", ok,
f"{prov}: key {'present' if ok else 'MISSING ('+str(need)+')'}"))
fail += not ok
# postgres (skip in demo)
if not a.demo:
ok, msg = await _ping_pg()
checks.append(("postgres", ok, msg)); fail += not ok
dry = os.environ.get("KANBOT_DRY_RUN", "1") == "1"
checks.append(("run-mode", True, "dry-run (proposals only)" if dry else "LIVE (executes)"))
payload = {"ok": fail == 0, "provider": prov,
"checks": [{"name": n, "ok": o, "detail": m} for n, o, m in checks]}
if a.json:
_out(payload, True)
else:
print(f"{BOLD} kanbot doctor {RESET} provider={prov}\n")
for n, o, m in checks:
mark = f"{GREEN}β{RESET}" if o else f"{RED}β{RESET}"
print(f" {mark} {n:<10} {DIM}{m}{RESET}")
print("\n " + (f"{GREEN}READY{RESET}" if fail == 0
else f"{RED}NOT READY: {fail} check(s) failed{RESET}"))
return 0 if fail == 0 else 1
async def _ping_ollama():
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
try:
import httpx
async with httpx.AsyncClient(timeout=4) as c:
r = await c.get(f"{host}/api/tags"); r.raise_for_status()
return True, f"ollama reachable at {host}"
except Exception as e:
return False, f"ollama unreachable at {host}: {e}"
async def _ping_pg():
dsn = os.environ.get("DATABASE_URL", "postgresql://kanbot:kanbot@localhost:5432/kanbot")
try:
import asyncpg
conn = await asyncpg.connect(dsn, timeout=4)
has = await conn.fetchval("SELECT to_regclass('public.cards') IS NOT NULL")
await conn.close()
return bool(has), "connected, schema present" if has else "connected, schema MISSING"
except Exception as e:
return False, f"cannot connect: {e}"
def cmd_config(a) -> int:
"""Resolved config with precedence env>default. Secrets are masked."""
def mask(v): return (v[:3] + "β¦" + v[-2:]) if v and len(v) > 6 else ("set" if v else None)
cfg = {
"KANBOT_PROVIDER": os.environ.get("KANBOT_PROVIDER", "ollama (default)"),
"KANBOT_DRY_RUN": os.environ.get("KANBOT_DRY_RUN", "1 (default)"),
"KANBOT_MAX_ATTEMPTS": os.environ.get("KANBOT_MAX_ATTEMPTS", "3 (default)"),
"KANBOT_SWARM_N": os.environ.get("KANBOT_SWARM_N", "1 (default)"),
"KANBOT_MAX_WORKERS": os.environ.get("KANBOT_MAX_WORKERS", "3 (default)"),
"DATABASE_URL": os.environ.get("DATABASE_URL", "postgresql://kanbot:kanbot@localhost:5432/kanbot (default)"),
"OLLAMA_HOST": os.environ.get("OLLAMA_HOST", "http://localhost:11434 (default)"),
"KANBOT_OPENAI_BASE_URL": os.environ.get("KANBOT_OPENAI_BASE_URL"),
"KANBOT_MODEL_FAST": os.environ.get("KANBOT_MODEL_FAST", "(provider default)"),
"KANBOT_MODEL_DAILY": os.environ.get("KANBOT_MODEL_DAILY", "(provider default)"),
"OPENAI_API_KEY": mask(os.environ.get("OPENAI_API_KEY")),
"ANTHROPIC_API_KEY": mask(os.environ.get("ANTHROPIC_API_KEY")),
"OPENROUTER_API_KEY": mask(os.environ.get("OPENROUTER_API_KEY")),
}
if a.json:
_out({"ok": True, "config": cfg}, True)
else:
print(f"{BOLD} kanbot config {RESET} {DIM}(env > default; secrets masked){RESET}")
for k, v in cfg.items():
print(f" {k:<24} {v}")
return 0
def cmd_run(a) -> int:
"""Launch the autonomous conductor loop. Sets run-mode from flags, then hands
off to core.conductor (the real scheduler). AUDIT: --live requires explicit
intent; without it we stay in dry-run (propose + park)."""
if a.live:
# AUDIT MODE: flipping to live execution is privileged β confirm first.
from cli import gumfx
if not gumfx.confirm(
"Flip KanBot to LIVE? Approved cards will EXECUTE shell calls "
"(sandboxed). Continue?", default_yes=False):
print(f"{YEL}aborted β staying in dry-run.{RESET}")
return 0
os.environ["KANBOT_DRY_RUN"] = "0"
elif a.dry_run:
os.environ["KANBOT_DRY_RUN"] = "1"
if a.swarm:
os.environ["KANBOT_SWARM_N"] = str(a.swarm)
mode = "LIVE β approved cards EXECUTE through the sandbox" if a.live else "dry-run (propose + park)"
print(f"{BOLD}[kanbot run]{RESET} provider={os.environ.get('KANBOT_PROVIDER','ollama')} "
f"| mode={mode} | swarm={os.environ.get('KANBOT_SWARM_N','1')}")
if a.demo:
print(f"{YEL}--demo:{RESET} conductor not started (no DB in demo). "
"Run without --demo on the M4 to drive the real loop.")
return 0
from core.conductor import main as conductor_main
try:
asyncio.run(conductor_main())
except KeyboardInterrupt:
print("\n[kanbot run] conductor stopped (in-flight leases reaped back to ready).")
return 0
# --------------------------------------------------------------------------- #
# arg parsing
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="kanbot",
description="KanBot β the board IS the runtime. CLI + agent harness.")
p.add_argument("--demo", action="store_true",
help="use a seeded in-memory board (no DB/provider needed)")
sub = p.add_subparsers(dest="cmd", required=True)
def add(name, fn, is_async, help):
sp = sub.add_parser(name, help=help)
sp.set_defaults(fn=fn, is_async=is_async)
return sp
sp = add("board", cmd_board, True, "show the board"); sp.add_argument("--json", action="store_true"); sp.add_argument("--demo", action="store_true")
sp = add("tui", cmd_tui, False, "interactive curses board"); sp.add_argument("--demo", action="store_true")
sp = add("agent", cmd_agent, False, "JSONL line-protocol loop (agent-driven)"); sp.add_argument("--demo", action="store_true")
sp = add("card", cmd_card, True, "card detail"); sp.add_argument("id"); sp.add_argument("--json", action="store_true")
sp = add("move", cmd_move, True, "guarded transition"); sp.add_argument("id"); sp.add_argument("column"); sp.add_argument("-m", "--reason", default=""); sp.add_argument("--json", action="store_true")
sp = add("claim", cmd_claim, True, "claim a ready card"); sp.add_argument("id"); sp.add_argument("worker"); sp.add_argument("--json", action="store_true")
sp = add("approve", cmd_approve, True, "unblock a proposal to ready"); sp.add_argument("id"); sp.add_argument("--json", action="store_true")
sp = add("block", cmd_block, True, "halt a card"); sp.add_argument("id"); sp.add_argument("reason"); sp.add_argument("--json", action="store_true")
sp = add("human", cmd_human, True, "flag for human decision"); sp.add_argument("id"); sp.add_argument("question"); sp.add_argument("--json", action="store_true")
sp = add("new", cmd_new, True, "create a card"); sp.add_argument("title"); sp.add_argument("-s", "--spec", default=""); sp.add_argument("-c", "--criteria", nargs="*"); sp.add_argument("--json", action="store_true")
sp = add("run", cmd_run, False, "autonomous conductor loop"); g = sp.add_mutually_exclusive_group(); g.add_argument("--dry-run", action="store_true"); g.add_argument("--live", action="store_true"); sp.add_argument("--swarm", type=int)
sp = add("providers", cmd_providers, False, "list providers + active + models"); sp.add_argument("--json", action="store_true")
sp = add("doctor", cmd_doctor, True, "preflight the active config"); sp.add_argument("--json", action="store_true")
sp = add("config", cmd_config, False, "show resolved config (secrets masked)"); sp.add_argument("--json", action="store_true")
return p
def main(argv=None) -> int:
# Existing process env wins. Tests import cli.tui without calling this,
# so the repo .env cannot leak model overrides into pytest.
from cli.tui import _load_repo_env
_load_repo_env()
parser = build_parser()
# --demo is accepted both globally (before the subcommand) and on
# board/tui/agent (after it). argparse would let the second occurrence
# clobber the first with False, so OR the two: if it appears anywhere, honor it.
raw = list(sys.argv[1:] if argv is None else argv)
demo_anywhere = "--demo" in raw
args = parser.parse_args(argv)
for attr in ("demo", "json"):
if not hasattr(args, attr):
setattr(args, attr, False)
if demo_anywhere:
args.demo = True
try:
if args.is_async:
return asyncio.run(args.fn(args))
return args.fn(args)
except KeyboardInterrupt:
return 130
except Exception as e:
payload = {"ok": False, "error": f"{type(e).__name__}: {e}"}
if getattr(args, "json", False):
print(json.dumps(payload))
else:
print(f"{RED}β {payload['error']}{RESET}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
|