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
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