diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/main.py b/cli/main.py new file mode 100644 index 0000000..e018127 --- /dev/null +++ b/cli/main.py @@ -0,0 +1,412 @@ +""" +Kin CLI — command-line interface for the multi-agent orchestrator. +Uses core.models for all data access, never raw SQL. +""" + +import json +import sys +from pathlib import Path + +import click + +# Ensure project root is on sys.path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core.db import init_db +from core import models + +DEFAULT_DB = Path.home() / ".kin" / "kin.db" + + +def get_conn(db_path: Path = DEFAULT_DB): + db_path.parent.mkdir(parents=True, exist_ok=True) + return init_db(db_path) + + +def _parse_json(ctx, param, value): + """Click callback: parse a JSON string or return None.""" + if value is None: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + raise click.BadParameter(f"Invalid JSON: {value}") + + +def _table(headers: list[str], rows: list[list[str]], min_width: int = 6): + """Render a simple aligned text table.""" + widths = [max(min_width, len(h)) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(widths): + widths[i] = max(widths[i], len(str(cell))) + fmt = " ".join(f"{{:<{w}}}" for w in widths) + lines = [fmt.format(*headers), fmt.format(*("-" * w for w in widths))] + for row in rows: + lines.append(fmt.format(*[str(c) for c in row])) + return "\n".join(lines) + + +def _auto_task_id(conn, project_id: str) -> str: + """Generate next task ID like PROJ-001.""" + prefix = project_id.upper() + existing = models.list_tasks(conn, project_id=project_id) + max_num = 0 + for t in existing: + tid = t["id"] + if tid.startswith(prefix + "-"): + try: + num = int(tid.split("-", 1)[1]) + max_num = max(max_num, num) + except ValueError: + pass + return f"{prefix}-{max_num + 1:03d}" + + +# =========================================================================== +# Root group +# =========================================================================== + +@click.group() +@click.option("--db", type=click.Path(), default=None, envvar="KIN_DB", + help="Path to kin.db (default: ~/.kin/kin.db, or $KIN_DB)") +@click.pass_context +def cli(ctx, db): + """Kin — multi-agent project orchestrator.""" + ctx.ensure_object(dict) + db_path = Path(db) if db else DEFAULT_DB + ctx.obj["conn"] = get_conn(db_path) + + +# =========================================================================== +# project +# =========================================================================== + +@cli.group() +def project(): + """Manage projects.""" + + +@project.command("add") +@click.argument("id") +@click.argument("name") +@click.argument("path") +@click.option("--tech-stack", callback=_parse_json, default=None, help='JSON array, e.g. \'["vue3","nuxt"]\'') +@click.option("--status", default="active") +@click.option("--priority", type=int, default=5) +@click.pass_context +def project_add(ctx, id, name, path, tech_stack, status, priority): + """Add a new project.""" + conn = ctx.obj["conn"] + p = models.create_project(conn, id, name, path, + tech_stack=tech_stack, status=status, priority=priority) + click.echo(f"Created project: {p['id']} ({p['name']})") + + +@project.command("list") +@click.option("--status", default=None) +@click.pass_context +def project_list(ctx, status): + """List projects.""" + conn = ctx.obj["conn"] + projects = models.list_projects(conn, status=status) + if not projects: + click.echo("No projects found.") + return + rows = [[p["id"], p["name"], p["status"], str(p["priority"]), p["path"]] + for p in projects] + click.echo(_table(["ID", "Name", "Status", "Pri", "Path"], rows)) + + +@project.command("show") +@click.argument("id") +@click.pass_context +def project_show(ctx, id): + """Show project details.""" + conn = ctx.obj["conn"] + p = models.get_project(conn, id) + if not p: + click.echo(f"Project '{id}' not found.", err=True) + raise SystemExit(1) + click.echo(f"Project: {p['id']}") + click.echo(f" Name: {p['name']}") + click.echo(f" Path: {p['path']}") + click.echo(f" Status: {p['status']}") + click.echo(f" Priority: {p['priority']}") + if p.get("tech_stack"): + click.echo(f" Tech stack: {', '.join(p['tech_stack'])}") + if p.get("forgejo_repo"): + click.echo(f" Forgejo: {p['forgejo_repo']}") + click.echo(f" Created: {p['created_at']}") + + +# =========================================================================== +# task +# =========================================================================== + +@cli.group() +def task(): + """Manage tasks.""" + + +@task.command("add") +@click.argument("project_id") +@click.argument("title") +@click.option("--type", "route_type", type=click.Choice(["debug", "feature", "refactor", "hotfix"]), default=None) +@click.option("--priority", type=int, default=5) +@click.pass_context +def task_add(ctx, project_id, title, route_type, priority): + """Add a task to a project. ID is auto-generated (PROJ-001).""" + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + task_id = _auto_task_id(conn, project_id) + brief = {"route_type": route_type} if route_type else None + t = models.create_task(conn, task_id, project_id, title, + priority=priority, brief=brief) + click.echo(f"Created task: {t['id']} — {t['title']}") + + +@task.command("list") +@click.option("--project", "project_id", default=None) +@click.option("--status", default=None) +@click.pass_context +def task_list(ctx, project_id, status): + """List tasks.""" + conn = ctx.obj["conn"] + tasks = models.list_tasks(conn, project_id=project_id, status=status) + if not tasks: + click.echo("No tasks found.") + return + rows = [[t["id"], t["project_id"], t["title"][:40], t["status"], + str(t["priority"]), t.get("assigned_role") or "-"] + for t in tasks] + click.echo(_table(["ID", "Project", "Title", "Status", "Pri", "Role"], rows)) + + +@task.command("show") +@click.argument("id") +@click.pass_context +def task_show(ctx, id): + """Show task details.""" + conn = ctx.obj["conn"] + t = models.get_task(conn, id) + if not t: + click.echo(f"Task '{id}' not found.", err=True) + raise SystemExit(1) + click.echo(f"Task: {t['id']}") + click.echo(f" Project: {t['project_id']}") + click.echo(f" Title: {t['title']}") + click.echo(f" Status: {t['status']}") + click.echo(f" Priority: {t['priority']}") + if t.get("assigned_role"): + click.echo(f" Role: {t['assigned_role']}") + if t.get("parent_task_id"): + click.echo(f" Parent: {t['parent_task_id']}") + if t.get("brief"): + click.echo(f" Brief: {json.dumps(t['brief'], ensure_ascii=False)}") + if t.get("spec"): + click.echo(f" Spec: {json.dumps(t['spec'], ensure_ascii=False)}") + click.echo(f" Created: {t['created_at']}") + click.echo(f" Updated: {t['updated_at']}") + + +# =========================================================================== +# decision +# =========================================================================== + +@cli.group() +def decision(): + """Manage decisions and gotchas.""" + + +@decision.command("add") +@click.argument("project_id") +@click.argument("type", type=click.Choice(["decision", "gotcha", "workaround", "rejected_approach", "convention"])) +@click.argument("title") +@click.argument("description") +@click.option("--category", default=None) +@click.option("--tags", callback=_parse_json, default=None, help='JSON array, e.g. \'["ios","css"]\'') +@click.option("--task-id", default=None) +@click.pass_context +def decision_add(ctx, project_id, type, title, description, category, tags, task_id): + """Record a decision, gotcha, or convention.""" + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + d = models.add_decision(conn, project_id, type, title, description, + category=category, tags=tags, task_id=task_id) + click.echo(f"Added {d['type']}: #{d['id']} — {d['title']}") + + +@decision.command("list") +@click.argument("project_id") +@click.option("--category", default=None) +@click.option("--tag", multiple=True, help="Filter by tag (can repeat)") +@click.option("--type", "types", multiple=True, + type=click.Choice(["decision", "gotcha", "workaround", "rejected_approach", "convention"]), + help="Filter by type (can repeat)") +@click.pass_context +def decision_list(ctx, project_id, category, tag, types): + """List decisions for a project.""" + conn = ctx.obj["conn"] + tags_list = list(tag) if tag else None + types_list = list(types) if types else None + decisions = models.get_decisions(conn, project_id, category=category, + tags=tags_list, types=types_list) + if not decisions: + click.echo("No decisions found.") + return + rows = [[str(d["id"]), d["type"], d["category"] or "-", + d["title"][:50], d["created_at"][:10]] + for d in decisions] + click.echo(_table(["#", "Type", "Category", "Title", "Date"], rows)) + + +# =========================================================================== +# module +# =========================================================================== + +@cli.group() +def module(): + """Manage project modules.""" + + +@module.command("add") +@click.argument("project_id") +@click.argument("name") +@click.argument("type", type=click.Choice(["frontend", "backend", "shared", "infra"])) +@click.argument("path") +@click.option("--description", default=None) +@click.option("--owner-role", default=None) +@click.pass_context +def module_add(ctx, project_id, name, type, path, description, owner_role): + """Register a project module.""" + conn = ctx.obj["conn"] + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + m = models.add_module(conn, project_id, name, type, path, + description=description, owner_role=owner_role) + click.echo(f"Added module: {m['name']} ({m['type']}) at {m['path']}") + + +@module.command("list") +@click.argument("project_id") +@click.pass_context +def module_list(ctx, project_id): + """List modules for a project.""" + conn = ctx.obj["conn"] + mods = models.get_modules(conn, project_id) + if not mods: + click.echo("No modules found.") + return + rows = [[m["name"], m["type"], m["path"], m.get("owner_role") or "-", + m.get("description") or ""] + for m in mods] + click.echo(_table(["Name", "Type", "Path", "Owner", "Description"], rows)) + + +# =========================================================================== +# status +# =========================================================================== + +@cli.command("status") +@click.argument("project_id", required=False) +@click.pass_context +def status(ctx, project_id): + """Project status overview. Without args — all projects. With id — detailed.""" + conn = ctx.obj["conn"] + + if project_id: + p = models.get_project(conn, project_id) + if not p: + click.echo(f"Project '{project_id}' not found.", err=True) + raise SystemExit(1) + tasks = models.list_tasks(conn, project_id=project_id) + counts = {} + for t in tasks: + counts[t["status"]] = counts.get(t["status"], 0) + 1 + + click.echo(f"Project: {p['id']} — {p['name']} [{p['status']}]") + click.echo(f" Path: {p['path']}") + if p.get("tech_stack"): + click.echo(f" Stack: {', '.join(p['tech_stack'])}") + click.echo(f" Tasks: {len(tasks)} total") + for s in ["pending", "in_progress", "review", "done", "blocked"]: + if counts.get(s, 0) > 0: + click.echo(f" {s}: {counts[s]}") + if tasks: + click.echo("") + rows = [[t["id"], t["title"][:40], t["status"], + t.get("assigned_role") or "-"] + for t in tasks] + click.echo(_table(["ID", "Title", "Status", "Role"], rows)) + else: + summary = models.get_project_summary(conn) + if not summary: + click.echo("No projects.") + return + rows = [[s["id"], s["name"][:25], s["status"], str(s["priority"]), + str(s["total_tasks"]), str(s["done_tasks"]), + str(s["active_tasks"]), str(s["blocked_tasks"])] + for s in summary] + click.echo(_table( + ["ID", "Name", "Status", "Pri", "Total", "Done", "Active", "Blocked"], + rows, + )) + + +# =========================================================================== +# cost +# =========================================================================== + +@cli.command("cost") +@click.option("--last", "period", default="7d", help="Period: 7d, 30d, etc.") +@click.pass_context +def cost(ctx, period): + """Show cost summary by project.""" + # Parse period like "7d", "30d" + period = period.strip().lower() + if period.endswith("d"): + try: + days = int(period[:-1]) + except ValueError: + click.echo(f"Invalid period: {period}. Use e.g. 7d, 30d.", err=True) + raise SystemExit(1) + else: + try: + days = int(period) + except ValueError: + click.echo(f"Invalid period: {period}. Use e.g. 7d, 30d.", err=True) + raise SystemExit(1) + + conn = ctx.obj["conn"] + costs = models.get_cost_summary(conn, days=days) + if not costs: + click.echo(f"No agent runs in the last {days} days.") + return + rows = [[c["project_id"], c["project_name"][:25], str(c["runs"]), + f"{c['total_tokens']:,}", f"${c['total_cost_usd']:.4f}", + f"{c['total_duration_seconds']}s"] + for c in costs] + click.echo(f"Cost summary (last {days} days):\n") + click.echo(_table( + ["Project", "Name", "Runs", "Tokens", "Cost", "Time"], + rows, + )) + total = sum(c["total_cost_usd"] for c in costs) + click.echo(f"\nTotal: ${total:.4f}") + + +# =========================================================================== +# Entry point +# =========================================================================== + +if __name__ == "__main__": + cli() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aaace4a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.backends._legacy:_Backend" + +[project] +name = "kin" +version = "0.1.0" +description = "Multi-agent project orchestrator" +requires-python = ">=3.11" +dependencies = ["click>=8.0"] + +[project.scripts] +kin = "cli.main:cli" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b19551b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,207 @@ +"""Tests for cli/main.py using click's CliRunner with in-memory-like temp DB.""" + +import json +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from cli.main import cli + + +@pytest.fixture +def runner(tmp_path): + """CliRunner that uses a temp DB file.""" + db_path = tmp_path / "test.db" + return CliRunner(), ["--db", str(db_path)] + + +def invoke(runner_tuple, args): + runner, base = runner_tuple + result = runner.invoke(cli, base + args) + return result + + +# -- project -- + +def test_project_add_and_list(runner): + r = invoke(runner, ["project", "add", "vdol", "В долю поперёк", + "~/projects/vdolipoperek", "--tech-stack", '["vue3","nuxt"]']) + assert r.exit_code == 0 + assert "vdol" in r.output + + r = invoke(runner, ["project", "list"]) + assert r.exit_code == 0 + assert "vdol" in r.output + assert "В долю поперёк" in r.output + + +def test_project_list_empty(runner): + r = invoke(runner, ["project", "list"]) + assert r.exit_code == 0 + assert "No projects" in r.output + + +def test_project_list_filter_status(runner): + invoke(runner, ["project", "add", "a", "A", "/a", "--status", "active"]) + invoke(runner, ["project", "add", "b", "B", "/b", "--status", "paused"]) + + r = invoke(runner, ["project", "list", "--status", "active"]) + assert "a" in r.output + assert "b" not in r.output + + +def test_project_show(runner): + invoke(runner, ["project", "add", "vdol", "В долю", "/vdol", + "--tech-stack", '["vue3"]', "--priority", "2"]) + r = invoke(runner, ["project", "show", "vdol"]) + assert r.exit_code == 0 + assert "vue3" in r.output + assert "Priority: 2" in r.output + + +def test_project_show_not_found(runner): + r = invoke(runner, ["project", "show", "nope"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +# -- task -- + +def test_task_add_and_list(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["task", "add", "p1", "Fix login bug", "--type", "debug"]) + assert r.exit_code == 0 + assert "P1-001" in r.output + + r = invoke(runner, ["task", "add", "p1", "Add search"]) + assert "P1-002" in r.output + + r = invoke(runner, ["task", "list"]) + assert "P1-001" in r.output + assert "P1-002" in r.output + + +def test_task_add_project_not_found(runner): + r = invoke(runner, ["task", "add", "nope", "Some task"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +def test_task_list_filter(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["project", "add", "p2", "P2", "/p2"]) + invoke(runner, ["task", "add", "p1", "A"]) + invoke(runner, ["task", "add", "p2", "B"]) + + r = invoke(runner, ["task", "list", "--project", "p1"]) + assert "P1-001" in r.output + assert "P2-001" not in r.output + + +def test_task_show(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "Fix bug", "--type", "debug"]) + r = invoke(runner, ["task", "show", "P1-001"]) + assert r.exit_code == 0 + assert "Fix bug" in r.output + + +def test_task_show_not_found(runner): + r = invoke(runner, ["task", "show", "X-999"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +# -- decision -- + +def test_decision_add_and_list(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["decision", "add", "p1", "gotcha", + "Safari bug", "position:fixed breaks", + "--category", "ui", "--tags", '["ios","css"]']) + assert r.exit_code == 0 + assert "gotcha" in r.output + + r = invoke(runner, ["decision", "list", "p1"]) + assert "Safari bug" in r.output + + +def test_decision_list_filter(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["decision", "add", "p1", "gotcha", "A", "a", "--category", "ui"]) + invoke(runner, ["decision", "add", "p1", "decision", "B", "b", "--category", "arch"]) + + r = invoke(runner, ["decision", "list", "p1", "--type", "gotcha"]) + assert "A" in r.output + assert "B" not in r.output + + +# -- module -- + +def test_module_add_and_list(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + r = invoke(runner, ["module", "add", "p1", "search", "frontend", "src/search/", + "--description", "Search UI"]) + assert r.exit_code == 0 + assert "search" in r.output + + r = invoke(runner, ["module", "list", "p1"]) + assert "search" in r.output + assert "Search UI" in r.output + + +# -- status -- + +def test_status_all(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "A"]) + invoke(runner, ["task", "add", "p1", "B"]) + + r = invoke(runner, ["status"]) + assert r.exit_code == 0 + assert "p1" in r.output + assert "2" in r.output # total tasks + + +def test_status_single_project(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + invoke(runner, ["task", "add", "p1", "A"]) + + r = invoke(runner, ["status", "p1"]) + assert r.exit_code == 0 + assert "P1-001" in r.output + assert "pending" in r.output + + +def test_status_not_found(runner): + r = invoke(runner, ["status", "nope"]) + assert r.exit_code == 1 + assert "not found" in r.output + + +# -- cost -- + +def test_cost_empty(runner): + r = invoke(runner, ["cost"]) + assert r.exit_code == 0 + assert "No agent runs" in r.output + + +def test_cost_with_data(runner): + invoke(runner, ["project", "add", "p1", "P1", "/p1"]) + # Insert agent log directly via models (no CLI command for this) + from core.db import init_db + from core import models as m + # Re-open the same DB the runner uses + db_path = runner[1][1] + conn = init_db(Path(db_path)) + m.log_agent_run(conn, "p1", "dev", "implement", + cost_usd=0.10, tokens_used=5000) + conn.close() + + r = invoke(runner, ["cost", "--last", "7d"]) + assert r.exit_code == 0 + assert "p1" in r.output + assert "$0.1000" in r.output