""" Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models. Run: uvicorn web.api:app --reload --port 8420 """ import sys from pathlib import Path # Ensure project root on sys.path sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from core.db import init_db from core import models from agents.bootstrap import ( detect_tech_stack, detect_modules, extract_decisions_from_claude_md, find_vault_root, scan_obsidian, save_to_db, ) DB_PATH = Path.home() / ".kin" / "kin.db" app = FastAPI(title="Kin API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_methods=["*"], allow_headers=["*"], ) def get_conn(): return init_db(DB_PATH) # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @app.get("/api/projects") def list_projects(status: str | None = None): conn = get_conn() summary = models.get_project_summary(conn) if status: summary = [s for s in summary if s["status"] == status] conn.close() return summary @app.get("/api/projects/{project_id}") def get_project(project_id: str): conn = get_conn() p = models.get_project(conn, project_id) if not p: conn.close() raise HTTPException(404, f"Project '{project_id}' not found") tasks = models.list_tasks(conn, project_id=project_id) mods = models.get_modules(conn, project_id) decisions = models.get_decisions(conn, project_id) conn.close() return {**p, "tasks": tasks, "modules": mods, "decisions": decisions} class ProjectCreate(BaseModel): id: str name: str path: str tech_stack: list[str] | None = None status: str = "active" priority: int = 5 @app.post("/api/projects") def create_project(body: ProjectCreate): conn = get_conn() if models.get_project(conn, body.id): conn.close() raise HTTPException(409, f"Project '{body.id}' already exists") p = models.create_project( conn, body.id, body.name, body.path, tech_stack=body.tech_stack, status=body.status, priority=body.priority, ) conn.close() return p # --------------------------------------------------------------------------- # Tasks # --------------------------------------------------------------------------- @app.get("/api/tasks/{task_id}") def get_task(task_id: str): conn = get_conn() t = models.get_task(conn, task_id) conn.close() if not t: raise HTTPException(404, f"Task '{task_id}' not found") return t class TaskCreate(BaseModel): project_id: str title: str priority: int = 5 route_type: str | None = None @app.post("/api/tasks") def create_task(body: TaskCreate): conn = get_conn() p = models.get_project(conn, body.project_id) if not p: conn.close() raise HTTPException(404, f"Project '{body.project_id}' not found") # Auto-generate task ID existing = models.list_tasks(conn, project_id=body.project_id) prefix = body.project_id.upper() max_num = 0 for t in existing: if t["id"].startswith(prefix + "-"): try: num = int(t["id"].split("-", 1)[1]) max_num = max(max_num, num) except ValueError: pass task_id = f"{prefix}-{max_num + 1:03d}" brief = {"route_type": body.route_type} if body.route_type else None t = models.create_task(conn, task_id, body.project_id, body.title, priority=body.priority, brief=brief) conn.close() return t # --------------------------------------------------------------------------- # Decisions # --------------------------------------------------------------------------- @app.get("/api/decisions") def list_decisions( project: str = Query(...), category: str | None = None, tag: list[str] | None = Query(None), type: list[str] | None = Query(None), ): conn = get_conn() decisions = models.get_decisions( conn, project, category=category, tags=tag, types=type, ) conn.close() return decisions class DecisionCreate(BaseModel): project_id: str type: str title: str description: str category: str | None = None tags: list[str] | None = None task_id: str | None = None @app.post("/api/decisions") def create_decision(body: DecisionCreate): conn = get_conn() p = models.get_project(conn, body.project_id) if not p: conn.close() raise HTTPException(404, f"Project '{body.project_id}' not found") d = models.add_decision( conn, body.project_id, body.type, body.title, body.description, category=body.category, tags=body.tags, task_id=body.task_id, ) conn.close() return d # --------------------------------------------------------------------------- # Cost # --------------------------------------------------------------------------- @app.get("/api/cost") def cost_summary(days: int = 7): conn = get_conn() costs = models.get_cost_summary(conn, days=days) conn.close() return costs # --------------------------------------------------------------------------- # Support # --------------------------------------------------------------------------- @app.get("/api/support/tickets") def list_tickets(project: str | None = None, status: str | None = None): conn = get_conn() tickets = models.list_tickets(conn, project_id=project, status=status) conn.close() return tickets # --------------------------------------------------------------------------- # Bootstrap # --------------------------------------------------------------------------- class BootstrapRequest(BaseModel): path: str id: str name: str vault_path: str | None = None @app.post("/api/bootstrap") def bootstrap(body: BootstrapRequest): project_path = Path(body.path).expanduser().resolve() if not project_path.is_dir(): raise HTTPException(400, f"Path '{body.path}' is not a directory") conn = get_conn() if models.get_project(conn, body.id): conn.close() raise HTTPException(409, f"Project '{body.id}' already exists") tech_stack = detect_tech_stack(project_path) modules = detect_modules(project_path) decisions = extract_decisions_from_claude_md(project_path, body.id, body.name) obsidian = None vault_root = find_vault_root(Path(body.vault_path) if body.vault_path else None) if vault_root: dir_name = project_path.name obs = scan_obsidian(vault_root, body.id, body.name, dir_name) if obs["tasks"] or obs["decisions"]: obsidian = obs save_to_db(conn, body.id, body.name, str(project_path), tech_stack, modules, decisions, obsidian) p = models.get_project(conn, body.id) conn.close() return { "project": p, "modules_count": len(modules), "decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])), "tasks_count": len((obsidian or {}).get("tasks", [])), }