Download cli/kanbot_cli.py from LoveLogicAI/kanbot-backend: direct link, hf CLI and curl.
- Browser
- Download file 16 kB
-
https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/refs%2Fpr%2F1/cli/kanbot_cli.py
- Command line
-
hf download hf://spaces/LoveLogicAI/kanbot-backend@refs/pr/1/cli/kanbot_cli.py
-
curl -L -o kanbot_cli.py https://huggingface.co/spaces/LoveLogicAI/kanbot-backend/resolve/refs%2Fpr%2F1/cli/kanbot_cli.py
16 kB
| #!/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()) | |