Add CLI (cli/main.py) — click-based interface for all core operations
Commands: project (add/list/show), task (add/list/show), decision (add/list), module (add/list), status, cost. Auto-generated task IDs (PROJ-001). DB at ~/.kin/kin.db or $KIN_DB. pyproject.toml with `kin` entry point. 18 CLI tests, 39 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3db73332ad
commit
432cfd55d4
4 changed files with 635 additions and 0 deletions
0
cli/__init__.py
Normal file
0
cli/__init__.py
Normal file
412
cli/main.py
Normal file
412
cli/main.py
Normal file
|
|
@ -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()
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
|
|
@ -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"]
|
||||||
207
tests/test_cli.py
Normal file
207
tests/test_cli.py
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue