Add bootstrap command — auto-detect project stack, modules, decisions
kin bootstrap <path> --id <id> --name <name> [--vault <path>] Detects: package.json, requirements.txt, go.mod, config files → tech_stack. Scans src/app/lib/frontend/backend dirs → modules with type detection. Parses CLAUDE.md for GOTCHA/WORKAROUND/FIXME/ВАЖНО → decisions. Scans Obsidian vault for kanban tasks, checkboxes, and decisions. Preview before save, -y to skip confirmation. 18 bootstrap tests, 57 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
432cfd55d4
commit
da4a8aae72
4 changed files with 957 additions and 0 deletions
0
agents/__init__.py
Normal file
0
agents/__init__.py
Normal file
564
agents/bootstrap.py
Normal file
564
agents/bootstrap.py
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
"""
|
||||
Kin bootstrap — auto-detect project tech stack, modules, and decisions.
|
||||
Scans project directory, CLAUDE.md, and optionally Obsidian vault.
|
||||
Writes results to kin.db via core.models.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_VAULT = Path.home() / "Library" / "Mobile Documents" / "iCloud~md~obsidian" / "Documents"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tech stack detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# package.json dependency → tech label
|
||||
_NPM_MARKERS = {
|
||||
"vue": "vue3", "nuxt": "nuxt3", "react": "react", "next": "nextjs",
|
||||
"svelte": "svelte", "angular": "angular",
|
||||
"typescript": "typescript", "vite": "vite", "webpack": "webpack",
|
||||
"express": "express", "fastify": "fastify", "koa": "koa",
|
||||
"pinia": "pinia", "vuex": "vuex", "redux": "redux",
|
||||
"tailwindcss": "tailwind", "prisma": "prisma", "drizzle-orm": "drizzle",
|
||||
"pg": "postgresql", "mysql2": "mysql", "better-sqlite3": "sqlite",
|
||||
"axios": "axios", "puppeteer": "puppeteer", "playwright": "playwright",
|
||||
}
|
||||
|
||||
# Config files → tech label
|
||||
_FILE_MARKERS = {
|
||||
"nuxt.config.ts": "nuxt3", "nuxt.config.js": "nuxt3",
|
||||
"vite.config.ts": "vite", "vite.config.js": "vite",
|
||||
"tsconfig.json": "typescript",
|
||||
"tailwind.config.js": "tailwind", "tailwind.config.ts": "tailwind",
|
||||
"docker-compose.yml": "docker", "docker-compose.yaml": "docker",
|
||||
"Dockerfile": "docker",
|
||||
"go.mod": "go", "Cargo.toml": "rust",
|
||||
"requirements.txt": "python", "pyproject.toml": "python",
|
||||
"setup.py": "python", "Pipfile": "python",
|
||||
".eslintrc.js": "eslint", ".prettierrc": "prettier",
|
||||
}
|
||||
|
||||
|
||||
def detect_tech_stack(project_path: Path) -> list[str]:
|
||||
"""Detect tech stack from project files."""
|
||||
stack: set[str] = set()
|
||||
|
||||
# Config file markers
|
||||
for fname, tech in _FILE_MARKERS.items():
|
||||
# Check root and one level deep
|
||||
if (project_path / fname).exists():
|
||||
stack.add(tech)
|
||||
for sub in ("frontend", "backend", "server", "client", "app"):
|
||||
if (project_path / sub / fname).exists():
|
||||
stack.add(tech)
|
||||
|
||||
# package.json (root + subdirs)
|
||||
for pj_path in _find_package_jsons(project_path):
|
||||
stack.update(_parse_package_json(pj_path))
|
||||
|
||||
# requirements.txt
|
||||
for req_path in project_path.glob("**/requirements.txt"):
|
||||
if _is_inside_node_modules(req_path, project_path):
|
||||
continue
|
||||
stack.update(_parse_requirements_txt(req_path))
|
||||
|
||||
# go.mod
|
||||
go_mod = project_path / "go.mod"
|
||||
if go_mod.exists():
|
||||
stack.add("go")
|
||||
text = go_mod.read_text(errors="replace")
|
||||
if "gin-gonic" in text:
|
||||
stack.add("gin")
|
||||
if "fiber" in text:
|
||||
stack.add("fiber")
|
||||
|
||||
return sorted(stack)
|
||||
|
||||
|
||||
def _find_package_jsons(root: Path) -> list[Path]:
|
||||
"""Find package.json files (root + immediate subdirs, skip node_modules)."""
|
||||
results = []
|
||||
pj = root / "package.json"
|
||||
if pj.exists():
|
||||
results.append(pj)
|
||||
for sub in root.iterdir():
|
||||
if sub.is_dir() and sub.name != "node_modules" and not sub.name.startswith("."):
|
||||
pj = sub / "package.json"
|
||||
if pj.exists():
|
||||
results.append(pj)
|
||||
return results
|
||||
|
||||
|
||||
def _parse_package_json(path: Path) -> list[str]:
|
||||
"""Extract tech labels from package.json."""
|
||||
try:
|
||||
data = json.loads(path.read_text(errors="replace"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
stack = []
|
||||
all_deps = {}
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
all_deps.update(data.get(key, {}))
|
||||
for dep_name, tech in _NPM_MARKERS.items():
|
||||
if dep_name in all_deps:
|
||||
stack.append(tech)
|
||||
return stack
|
||||
|
||||
|
||||
def _parse_requirements_txt(path: Path) -> list[str]:
|
||||
"""Extract tech labels from requirements.txt."""
|
||||
markers = {
|
||||
"fastapi": "fastapi", "flask": "flask", "django": "django",
|
||||
"sqlalchemy": "sqlalchemy", "celery": "celery", "redis": "redis",
|
||||
"pydantic": "pydantic", "click": "click", "pytest": "pytest",
|
||||
}
|
||||
stack = []
|
||||
try:
|
||||
text = path.read_text(errors="replace").lower()
|
||||
except OSError:
|
||||
return stack
|
||||
for pkg, tech in markers.items():
|
||||
if pkg in text:
|
||||
stack.append(tech)
|
||||
return stack
|
||||
|
||||
|
||||
def _is_inside_node_modules(path: Path, root: Path) -> bool:
|
||||
rel = path.relative_to(root)
|
||||
return "node_modules" in rel.parts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRONTEND_EXTS = {".vue", ".jsx", ".tsx", ".svelte"}
|
||||
_BACKEND_MARKERS = {"express", "fastify", "koa", "router", "controller", "middleware"}
|
||||
|
||||
|
||||
def detect_modules(project_path: Path) -> list[dict]:
|
||||
"""Scan src/ (or app/, lib/, frontend/, backend/) for modules."""
|
||||
modules = []
|
||||
scan_dirs = []
|
||||
|
||||
# Prioritized source dirs
|
||||
for name in ("src", "app", "lib", "frontend", "backend", "server", "client"):
|
||||
d = project_path / name
|
||||
if d.is_dir():
|
||||
scan_dirs.append(d)
|
||||
|
||||
# Also check frontend/src, backend/src patterns
|
||||
for name in ("frontend/src", "backend/src", "backend-pg/src"):
|
||||
d = project_path / name
|
||||
if d.is_dir():
|
||||
scan_dirs.append(d)
|
||||
|
||||
seen = set()
|
||||
for scan_dir in scan_dirs:
|
||||
for child in sorted(scan_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith(".") or child.name == "node_modules":
|
||||
continue
|
||||
mod = _analyze_module(child, project_path)
|
||||
key = (mod["name"], mod["path"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
modules.append(mod)
|
||||
|
||||
return modules
|
||||
|
||||
|
||||
def _analyze_module(dir_path: Path, project_root: Path) -> dict:
|
||||
"""Analyze a directory to determine module type and file count."""
|
||||
rel_path = str(dir_path.relative_to(project_root)) + "/"
|
||||
files = list(dir_path.rglob("*"))
|
||||
source_files = [f for f in files if f.is_file() and not f.name.startswith(".")]
|
||||
file_count = len(source_files)
|
||||
|
||||
# Determine type
|
||||
exts = {f.suffix for f in source_files}
|
||||
mod_type = _guess_module_type(dir_path, exts, source_files)
|
||||
|
||||
return {
|
||||
"name": dir_path.name,
|
||||
"type": mod_type,
|
||||
"path": rel_path,
|
||||
"file_count": file_count,
|
||||
}
|
||||
|
||||
|
||||
def _guess_module_type(dir_path: Path, exts: set[str], files: list[Path]) -> str:
|
||||
"""Guess if module is frontend, backend, shared, or infra."""
|
||||
# Obvious frontend
|
||||
if exts & _FRONTEND_EXTS:
|
||||
return "frontend"
|
||||
|
||||
# Check file contents for backend markers
|
||||
has_backend_marker = False
|
||||
for f in files[:20]: # Sample first 20 files
|
||||
if f.suffix in (".ts", ".js", ".mjs"):
|
||||
try:
|
||||
text = f.read_text(errors="replace")[:2000]
|
||||
text_lower = text.lower()
|
||||
if any(m in text_lower for m in _BACKEND_MARKERS):
|
||||
has_backend_marker = True
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if has_backend_marker:
|
||||
return "backend"
|
||||
|
||||
# Infra patterns
|
||||
name = dir_path.name.lower()
|
||||
if name in ("infra", "deploy", "scripts", "ci", "docker", "nginx", "config"):
|
||||
return "infra"
|
||||
|
||||
# Shared by default if ambiguous
|
||||
if exts & {".ts", ".js", ".py"}:
|
||||
return "shared"
|
||||
|
||||
return "shared"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions from CLAUDE.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DECISION_PATTERNS = [
|
||||
(r"(?i)\b(GOTCHA|ВАЖНО|WARNING|ВНИМАНИЕ)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "gotcha"),
|
||||
(r"(?i)\b(WORKAROUND|ОБХОДНОЙ|ХАК)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "workaround"),
|
||||
(r"(?i)\b(FIXME|TODO|БАГИ?)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "gotcha"),
|
||||
(r"(?i)\b(РЕШЕНИЕ|DECISION)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "decision"),
|
||||
(r"(?i)\b(CONVENTION|СОГЛАШЕНИЕ|ПРАВИЛО)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "convention"),
|
||||
]
|
||||
|
||||
# Section headers that likely contain decisions
|
||||
_DECISION_SECTIONS = [
|
||||
r"(?i)known\s+issues?", r"(?i)workaround", r"(?i)gotcha",
|
||||
r"(?i)решени[яе]", r"(?i)грабл[ия]", r"(?i)важно",
|
||||
r"(?i)conventions?", r"(?i)правила", r"(?i)нюансы",
|
||||
]
|
||||
|
||||
|
||||
def extract_decisions_from_claude_md(project_path: Path) -> list[dict]:
|
||||
"""Parse CLAUDE.md for decisions, gotchas, workarounds."""
|
||||
claude_md = project_path / "CLAUDE.md"
|
||||
if not claude_md.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
text = claude_md.read_text(errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
decisions = []
|
||||
seen_titles = set()
|
||||
|
||||
# Pattern-based extraction
|
||||
for pattern, dec_type in _DECISION_PATTERNS:
|
||||
for m in re.finditer(pattern, text, re.DOTALL):
|
||||
label = m.group(1).strip()
|
||||
body = m.group(2).strip()
|
||||
if not body or len(body) < 10:
|
||||
continue
|
||||
# First line as title, rest as description
|
||||
lines = body.split("\n")
|
||||
title = lines[0].strip().rstrip(".")[:100]
|
||||
desc = body
|
||||
if title not in seen_titles:
|
||||
seen_titles.add(title)
|
||||
decisions.append({
|
||||
"type": dec_type,
|
||||
"title": title,
|
||||
"description": desc,
|
||||
"category": _guess_category(title + " " + desc),
|
||||
})
|
||||
|
||||
# Section-based extraction: find headers matching decision sections
|
||||
sections = re.split(r"(?m)^(#{1,4}\s+.*?)$", text)
|
||||
for i, section in enumerate(sections):
|
||||
if any(re.search(pat, section) for pat in _DECISION_SECTIONS):
|
||||
# The content is in the next section
|
||||
if i + 1 < len(sections):
|
||||
content = sections[i + 1].strip()
|
||||
# Extract bullet points
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith(("- ", "* ", "• ")):
|
||||
item = line.lstrip("-*• ").strip()
|
||||
if item and len(item) > 10 and item[:80] not in seen_titles:
|
||||
seen_titles.add(item[:80])
|
||||
decisions.append({
|
||||
"type": "decision",
|
||||
"title": item[:100],
|
||||
"description": item,
|
||||
"category": _guess_category(item),
|
||||
})
|
||||
|
||||
return decisions
|
||||
|
||||
|
||||
def _guess_category(text: str) -> str:
|
||||
"""Best-effort category guess from text content."""
|
||||
t = text.lower()
|
||||
if any(w in t for w in ("css", "ui", "vue", "компонент", "стил", "layout", "mobile", "safari", "bottom-sheet")):
|
||||
return "ui"
|
||||
if any(w in t for w in ("api", "endpoint", "rest", "route", "запрос", "fetch")):
|
||||
return "api"
|
||||
if any(w in t for w in ("sql", "база", "миграц", "postgres", "sqlite", "бд", "schema")):
|
||||
return "architecture"
|
||||
if any(w in t for w in ("безопас", "security", "xss", "auth", "token", "csrf", "injection")):
|
||||
return "security"
|
||||
if any(w in t for w in ("docker", "deploy", "nginx", "ci", "cd", "infra", "сервер")):
|
||||
return "devops"
|
||||
if any(w in t for w in ("performance", "cache", "оптимиз", "lazy", "скорость")):
|
||||
return "performance"
|
||||
return "architecture"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Obsidian vault scanning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_vault_root(vault_path: Path | None = None) -> Path | None:
|
||||
"""Find the Obsidian vault root directory.
|
||||
|
||||
If vault_path is given but doesn't exist, returns None (don't fallback).
|
||||
If vault_path is None, tries the default iCloud Obsidian location.
|
||||
"""
|
||||
if vault_path is not None:
|
||||
return vault_path if vault_path.is_dir() else None
|
||||
|
||||
# Default: iCloud Obsidian path
|
||||
default = DEFAULT_VAULT
|
||||
if default.is_dir():
|
||||
# Look for a vault inside (usually one level deep)
|
||||
for child in default.iterdir():
|
||||
if child.is_dir() and not child.name.startswith("."):
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def scan_obsidian(
|
||||
vault_root: Path,
|
||||
project_id: str,
|
||||
project_name: str,
|
||||
project_dir_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Scan Obsidian vault for project-related notes.
|
||||
|
||||
Returns {"tasks": [...], "decisions": [...], "files_scanned": int}
|
||||
"""
|
||||
result = {"tasks": [], "decisions": [], "files_scanned": 0}
|
||||
|
||||
# Build search terms
|
||||
search_terms = {project_id.lower()}
|
||||
if project_name:
|
||||
search_terms.add(project_name.lower())
|
||||
if project_dir_name:
|
||||
search_terms.add(project_dir_name.lower())
|
||||
|
||||
# Find project folder in vault
|
||||
project_files: list[Path] = []
|
||||
for term in list(search_terms):
|
||||
for child in vault_root.iterdir():
|
||||
if child.is_dir() and term in child.name.lower():
|
||||
for f in child.rglob("*.md"):
|
||||
if f not in project_files:
|
||||
project_files.append(f)
|
||||
|
||||
# Also search for files mentioning the project by name
|
||||
for md_file in vault_root.glob("*.md"):
|
||||
try:
|
||||
text = md_file.read_text(errors="replace")[:5000].lower()
|
||||
except OSError:
|
||||
continue
|
||||
if any(term in text for term in search_terms):
|
||||
if md_file not in project_files:
|
||||
project_files.append(md_file)
|
||||
|
||||
result["files_scanned"] = len(project_files)
|
||||
|
||||
for f in project_files:
|
||||
try:
|
||||
text = f.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
_extract_obsidian_tasks(text, f.stem, result["tasks"])
|
||||
_extract_obsidian_decisions(text, f.stem, result["decisions"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_obsidian_tasks(text: str, source: str, tasks: list[dict]):
|
||||
"""Extract checkbox items from Obsidian markdown."""
|
||||
for m in re.finditer(r"^[-*]\s+\[([ xX])\]\s+(.+)$", text, re.MULTILINE):
|
||||
done = m.group(1).lower() == "x"
|
||||
title = m.group(2).strip()
|
||||
# Remove Obsidian wiki-links
|
||||
title = re.sub(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", r"\1", title)
|
||||
if len(title) > 5:
|
||||
tasks.append({
|
||||
"title": title[:200],
|
||||
"done": done,
|
||||
"source": source,
|
||||
})
|
||||
|
||||
|
||||
def _extract_obsidian_decisions(text: str, source: str, decisions: list[dict]):
|
||||
"""Extract decisions/gotchas from Obsidian notes."""
|
||||
for pattern, dec_type in _DECISION_PATTERNS:
|
||||
for m in re.finditer(pattern, text, re.DOTALL):
|
||||
body = m.group(2).strip()
|
||||
if body and len(body) > 10:
|
||||
title = body.split("\n")[0].strip()[:100]
|
||||
decisions.append({
|
||||
"type": dec_type,
|
||||
"title": title,
|
||||
"description": body,
|
||||
"category": _guess_category(body),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
# Also look for ВАЖНО/GOTCHA/FIXME inline markers not caught above
|
||||
for m in re.finditer(r"(?i)\*\*(ВАЖНО|GOTCHA|FIXME)\*\*[:\s]*(.*?)(?=\n|$)", text):
|
||||
body = m.group(2).strip()
|
||||
if body and len(body) > 10:
|
||||
decisions.append({
|
||||
"type": "gotcha",
|
||||
"title": body[:100],
|
||||
"description": body,
|
||||
"category": _guess_category(body),
|
||||
"source": source,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting for CLI preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_preview(
|
||||
project_id: str,
|
||||
name: str,
|
||||
path: str,
|
||||
tech_stack: list[str],
|
||||
modules: list[dict],
|
||||
decisions: list[dict],
|
||||
obsidian: dict | None = None,
|
||||
) -> str:
|
||||
"""Format bootstrap results for user review."""
|
||||
lines = [
|
||||
f"Project: {project_id} — {name}",
|
||||
f"Path: {path}",
|
||||
"",
|
||||
f"Tech stack: {', '.join(tech_stack) if tech_stack else '(not detected)'}",
|
||||
"",
|
||||
]
|
||||
|
||||
if modules:
|
||||
lines.append(f"Modules ({len(modules)}):")
|
||||
for m in modules:
|
||||
lines.append(f" {m['name']} ({m['type']}) — {m['path']} ({m['file_count']} files)")
|
||||
else:
|
||||
lines.append("Modules: (none detected)")
|
||||
lines.append("")
|
||||
|
||||
if decisions:
|
||||
lines.append(f"Decisions from CLAUDE.md ({len(decisions)}):")
|
||||
for i, d in enumerate(decisions, 1):
|
||||
lines.append(f" #{i} {d['type']}: {d['title']}")
|
||||
else:
|
||||
lines.append("Decisions from CLAUDE.md: (none found)")
|
||||
|
||||
if obsidian:
|
||||
lines.append("")
|
||||
lines.append(f"Obsidian vault ({obsidian['files_scanned']} files scanned):")
|
||||
if obsidian["tasks"]:
|
||||
pending = [t for t in obsidian["tasks"] if not t["done"]]
|
||||
done = [t for t in obsidian["tasks"] if t["done"]]
|
||||
lines.append(f" Tasks: {len(pending)} pending, {len(done)} done")
|
||||
for t in pending[:10]:
|
||||
lines.append(f" [ ] {t['title']}")
|
||||
if len(pending) > 10:
|
||||
lines.append(f" ... and {len(pending) - 10} more")
|
||||
for t in done[:5]:
|
||||
lines.append(f" [x] {t['title']}")
|
||||
if len(done) > 5:
|
||||
lines.append(f" ... and {len(done) - 5} more done")
|
||||
else:
|
||||
lines.append(" Tasks: (none found)")
|
||||
if obsidian["decisions"]:
|
||||
lines.append(f" Decisions: {len(obsidian['decisions'])}")
|
||||
for d in obsidian["decisions"][:5]:
|
||||
lines.append(f" {d['type']}: {d['title']} (from {d['source']})")
|
||||
if len(obsidian["decisions"]) > 5:
|
||||
lines.append(f" ... and {len(obsidian['decisions']) - 5} more")
|
||||
else:
|
||||
lines.append(" Decisions: (none found)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Write to DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_to_db(
|
||||
conn,
|
||||
project_id: str,
|
||||
name: str,
|
||||
path: str,
|
||||
tech_stack: list[str],
|
||||
modules: list[dict],
|
||||
decisions: list[dict],
|
||||
obsidian: dict | None = None,
|
||||
):
|
||||
"""Save all bootstrap data to kin.db via models."""
|
||||
from core import models
|
||||
|
||||
# Create project
|
||||
claude_md = Path(path).expanduser() / "CLAUDE.md"
|
||||
models.create_project(
|
||||
conn, project_id, name, path,
|
||||
tech_stack=tech_stack,
|
||||
claude_md_path=str(claude_md) if claude_md.exists() else None,
|
||||
)
|
||||
|
||||
# Add modules
|
||||
for m in modules:
|
||||
models.add_module(
|
||||
conn, project_id, m["name"], m["type"], m["path"],
|
||||
description=f"{m['file_count']} files",
|
||||
)
|
||||
|
||||
# Add decisions from CLAUDE.md
|
||||
for d in decisions:
|
||||
models.add_decision(
|
||||
conn, project_id, d["type"], d["title"], d["description"],
|
||||
category=d.get("category"),
|
||||
)
|
||||
|
||||
# Add Obsidian decisions
|
||||
if obsidian:
|
||||
for d in obsidian.get("decisions", []):
|
||||
models.add_decision(
|
||||
conn, project_id, d["type"], d["title"], d["description"],
|
||||
category=d.get("category"),
|
||||
tags=[f"obsidian:{d['source']}"],
|
||||
)
|
||||
|
||||
# Import Obsidian tasks
|
||||
task_num = 1
|
||||
for t in obsidian.get("tasks", []):
|
||||
task_id = f"{project_id.upper()}-OBS-{task_num:03d}"
|
||||
status = "done" if t["done"] else "pending"
|
||||
models.create_task(
|
||||
conn, task_id, project_id, t["title"],
|
||||
status=status,
|
||||
brief={"source": f"obsidian:{t['source']}"},
|
||||
)
|
||||
task_num += 1
|
||||
69
cli/main.py
69
cli/main.py
|
|
@ -14,6 +14,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
|
||||
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, format_preview, save_to_db,
|
||||
)
|
||||
|
||||
DEFAULT_DB = Path.home() / ".kin" / "kin.db"
|
||||
|
||||
|
|
@ -404,6 +408,71 @@ def cost(ctx, period):
|
|||
click.echo(f"\nTotal: ${total:.4f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# bootstrap
|
||||
# ===========================================================================
|
||||
|
||||
@cli.command("bootstrap")
|
||||
@click.argument("path", type=click.Path(exists=True))
|
||||
@click.option("--id", "project_id", required=True, help="Short project ID (e.g. vdol)")
|
||||
@click.option("--name", required=True, help="Project display name")
|
||||
@click.option("--vault", "vault_path", type=click.Path(), default=None,
|
||||
help="Obsidian vault path (auto-detected if omitted)")
|
||||
@click.option("-y", "--yes", is_flag=True, help="Skip confirmation")
|
||||
@click.pass_context
|
||||
def bootstrap(ctx, path, project_id, name, vault_path, yes):
|
||||
"""Auto-detect project stack, modules, decisions and import into Kin."""
|
||||
conn = ctx.obj["conn"]
|
||||
project_path = Path(path).expanduser().resolve()
|
||||
|
||||
# Check if project already exists
|
||||
existing = models.get_project(conn, project_id)
|
||||
if existing:
|
||||
click.echo(f"Project '{project_id}' already exists. Use 'kin project show {project_id}'.", err=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Detect everything
|
||||
click.echo(f"Scanning {project_path} ...")
|
||||
tech_stack = detect_tech_stack(project_path)
|
||||
modules = detect_modules(project_path)
|
||||
decisions = extract_decisions_from_claude_md(project_path)
|
||||
|
||||
# Obsidian
|
||||
obsidian = None
|
||||
vault_root = find_vault_root(Path(vault_path) if vault_path else None)
|
||||
if vault_root:
|
||||
dir_name = project_path.name
|
||||
obsidian = scan_obsidian(vault_root, project_id, name, dir_name)
|
||||
if not obsidian["tasks"] and not obsidian["decisions"]:
|
||||
obsidian = None # Nothing found, don't clutter output
|
||||
|
||||
# Preview
|
||||
click.echo("")
|
||||
click.echo(format_preview(
|
||||
project_id, name, str(project_path), tech_stack,
|
||||
modules, decisions, obsidian,
|
||||
))
|
||||
click.echo("")
|
||||
|
||||
if not yes:
|
||||
if not click.confirm("Save to kin.db?"):
|
||||
click.echo("Aborted.")
|
||||
return
|
||||
|
||||
save_to_db(conn, project_id, name, str(project_path),
|
||||
tech_stack, modules, decisions, obsidian)
|
||||
|
||||
# Summary
|
||||
task_count = 0
|
||||
dec_count = len(decisions)
|
||||
if obsidian:
|
||||
task_count += len(obsidian.get("tasks", []))
|
||||
dec_count += len(obsidian.get("decisions", []))
|
||||
|
||||
click.echo(f"Saved: 1 project, {len(modules)} modules, "
|
||||
f"{dec_count} decisions, {task_count} tasks.")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Entry point
|
||||
# ===========================================================================
|
||||
|
|
|
|||
324
tests/test_bootstrap.py
Normal file
324
tests/test_bootstrap.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""Tests for agents/bootstrap.py — tech detection, modules, decisions, obsidian."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from click.testing import CliRunner
|
||||
|
||||
from agents.bootstrap import (
|
||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||
find_vault_root, scan_obsidian, format_preview, save_to_db,
|
||||
)
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
from cli.main import cli
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tech stack detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detect_node_project(tmp_path):
|
||||
(tmp_path / "package.json").write_text(json.dumps({
|
||||
"dependencies": {"vue": "^3.4", "pinia": "^2.0"},
|
||||
"devDependencies": {"typescript": "^5.0", "vite": "^5.0"},
|
||||
}))
|
||||
(tmp_path / "tsconfig.json").write_text("{}")
|
||||
(tmp_path / "nuxt.config.ts").write_text("export default {}")
|
||||
|
||||
stack = detect_tech_stack(tmp_path)
|
||||
assert "vue3" in stack
|
||||
assert "typescript" in stack
|
||||
assert "nuxt3" in stack
|
||||
assert "pinia" in stack
|
||||
assert "vite" in stack
|
||||
|
||||
|
||||
def test_detect_python_project(tmp_path):
|
||||
(tmp_path / "requirements.txt").write_text("fastapi==0.104\npydantic>=2.0\n")
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
|
||||
stack = detect_tech_stack(tmp_path)
|
||||
assert "python" in stack
|
||||
assert "fastapi" in stack
|
||||
assert "pydantic" in stack
|
||||
|
||||
|
||||
def test_detect_go_project(tmp_path):
|
||||
(tmp_path / "go.mod").write_text("module example.com/foo\nrequire gin-gonic v1.9\n")
|
||||
|
||||
stack = detect_tech_stack(tmp_path)
|
||||
assert "go" in stack
|
||||
assert "gin" in stack
|
||||
|
||||
|
||||
def test_detect_monorepo(tmp_path):
|
||||
fe = tmp_path / "frontend"
|
||||
fe.mkdir()
|
||||
(fe / "package.json").write_text(json.dumps({
|
||||
"dependencies": {"vue": "^3.0"},
|
||||
}))
|
||||
be = tmp_path / "backend"
|
||||
be.mkdir()
|
||||
(be / "requirements.txt").write_text("fastapi\n")
|
||||
|
||||
stack = detect_tech_stack(tmp_path)
|
||||
assert "vue3" in stack
|
||||
assert "fastapi" in stack
|
||||
|
||||
|
||||
def test_detect_empty_dir(tmp_path):
|
||||
assert detect_tech_stack(tmp_path) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detect_modules_vue(tmp_path):
|
||||
src = tmp_path / "src"
|
||||
(src / "components" / "search").mkdir(parents=True)
|
||||
(src / "components" / "search" / "Search.vue").write_text("<template></template>")
|
||||
(src / "components" / "search" / "SearchFilter.vue").write_text("<template></template>")
|
||||
(src / "api" / "auth").mkdir(parents=True)
|
||||
(src / "api" / "auth" / "login.ts").write_text("import express from 'express';\nconst router = express.Router();")
|
||||
|
||||
modules = detect_modules(tmp_path)
|
||||
names = {m["name"] for m in modules}
|
||||
assert "components" in names or "search" in names
|
||||
assert "api" in names or "auth" in names
|
||||
|
||||
|
||||
def test_detect_modules_empty(tmp_path):
|
||||
assert detect_modules(tmp_path) == []
|
||||
|
||||
|
||||
def test_detect_modules_backend_pg(tmp_path):
|
||||
"""Test detection in backend-pg/src/ pattern (like vdolipoperek)."""
|
||||
src = tmp_path / "backend-pg" / "src" / "services"
|
||||
src.mkdir(parents=True)
|
||||
(src / "tourMapper.js").write_text("const express = require('express');")
|
||||
(src / "dbService.js").write_text("module.exports = { query };")
|
||||
|
||||
modules = detect_modules(tmp_path)
|
||||
assert any(m["name"] == "services" for m in modules)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions from CLAUDE.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_extract_decisions(tmp_path):
|
||||
(tmp_path / "CLAUDE.md").write_text("""# Project
|
||||
|
||||
## Rules
|
||||
- Use WAL mode for SQLite
|
||||
|
||||
ВАЖНО: docker-compose v1 глючит → только raw docker commands
|
||||
WORKAROUND: position:fixed breaks on iOS Safari, use transform instead
|
||||
GOTCHA: Sletat API бан при параллельных запросах
|
||||
FIXME: race condition in useSearch composable
|
||||
|
||||
## Known Issues
|
||||
- Mobile bottom-sheet не работает в landscape mode
|
||||
- CSS grid fallback для IE11 (но мы его не поддерживаем)
|
||||
""")
|
||||
|
||||
decisions = extract_decisions_from_claude_md(tmp_path)
|
||||
assert len(decisions) >= 4
|
||||
|
||||
types = {d["type"] for d in decisions}
|
||||
assert "gotcha" in types
|
||||
assert "workaround" in types
|
||||
|
||||
|
||||
def test_extract_decisions_no_claude_md(tmp_path):
|
||||
assert extract_decisions_from_claude_md(tmp_path) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Obsidian vault
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_scan_obsidian(tmp_path):
|
||||
# Create a mock vault
|
||||
vault = tmp_path / "vault"
|
||||
proj_dir = vault / "myproject"
|
||||
proj_dir.mkdir(parents=True)
|
||||
|
||||
(proj_dir / "kanban.md").write_text("""---
|
||||
kanban-plugin: board
|
||||
---
|
||||
|
||||
## В работе
|
||||
- [ ] Fix login page
|
||||
- [ ] Add search filter
|
||||
- [x] Setup CI/CD
|
||||
|
||||
## Done
|
||||
- [x] Initial deploy
|
||||
|
||||
**ВАЖНО:** Не забыть обновить SSL сертификат
|
||||
""")
|
||||
|
||||
(proj_dir / "notes.md").write_text("""# Notes
|
||||
GOTCHA: API rate limit is 10 req/s
|
||||
- [ ] Write tests for auth module
|
||||
""")
|
||||
|
||||
result = scan_obsidian(vault, "myproject", "My Project", "myproject")
|
||||
assert result["files_scanned"] == 2
|
||||
assert len(result["tasks"]) >= 4 # 3 pending + at least 1 done
|
||||
assert len(result["decisions"]) >= 1 # At least the ВАЖНО one
|
||||
|
||||
pending = [t for t in result["tasks"] if not t["done"]]
|
||||
done = [t for t in result["tasks"] if t["done"]]
|
||||
assert len(pending) >= 3
|
||||
assert len(done) >= 1
|
||||
|
||||
|
||||
def test_scan_obsidian_no_match(tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
(vault / "other.md").write_text("# Unrelated note\nSomething else.")
|
||||
|
||||
result = scan_obsidian(vault, "myproject", "My Project")
|
||||
assert result["files_scanned"] == 0
|
||||
assert result["tasks"] == []
|
||||
|
||||
|
||||
def test_find_vault_root_explicit(tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
assert find_vault_root(vault) == vault
|
||||
|
||||
|
||||
def test_find_vault_root_none():
|
||||
assert find_vault_root(Path("/nonexistent/path")) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Save to DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_save_to_db(tmp_path):
|
||||
conn = init_db(":memory:")
|
||||
|
||||
save_to_db(
|
||||
conn,
|
||||
project_id="test",
|
||||
name="Test Project",
|
||||
path=str(tmp_path),
|
||||
tech_stack=["python", "fastapi"],
|
||||
modules=[
|
||||
{"name": "api", "type": "backend", "path": "src/api/", "file_count": 5},
|
||||
{"name": "ui", "type": "frontend", "path": "src/ui/", "file_count": 8},
|
||||
],
|
||||
decisions=[
|
||||
{"type": "gotcha", "title": "Bug X", "description": "desc",
|
||||
"category": "ui"},
|
||||
],
|
||||
obsidian={
|
||||
"tasks": [
|
||||
{"title": "Fix login", "done": False, "source": "kanban"},
|
||||
{"title": "Setup CI", "done": True, "source": "kanban"},
|
||||
],
|
||||
"decisions": [
|
||||
{"type": "gotcha", "title": "API limit", "description": "10 req/s",
|
||||
"category": "api", "source": "notes"},
|
||||
],
|
||||
"files_scanned": 2,
|
||||
},
|
||||
)
|
||||
|
||||
p = models.get_project(conn, "test")
|
||||
assert p is not None
|
||||
assert p["tech_stack"] == ["python", "fastapi"]
|
||||
|
||||
mods = models.get_modules(conn, "test")
|
||||
assert len(mods) == 2
|
||||
|
||||
decs = models.get_decisions(conn, "test")
|
||||
assert len(decs) == 2 # 1 from CLAUDE.md + 1 from Obsidian
|
||||
|
||||
tasks = models.list_tasks(conn, project_id="test")
|
||||
assert len(tasks) == 2 # 2 from Obsidian
|
||||
assert any(t["status"] == "done" for t in tasks)
|
||||
assert any(t["status"] == "pending" for t in tasks)
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_format_preview():
|
||||
text = format_preview(
|
||||
"vdol", "ВДОЛЬ", "~/projects/vdol",
|
||||
["vue3", "typescript"],
|
||||
[{"name": "search", "type": "frontend", "path": "src/search/", "file_count": 4}],
|
||||
[{"type": "gotcha", "title": "Safari bug"}],
|
||||
{"files_scanned": 3, "tasks": [
|
||||
{"title": "Fix X", "done": False, "source": "kb"},
|
||||
], "decisions": []},
|
||||
)
|
||||
assert "vue3" in text
|
||||
assert "search" in text
|
||||
assert "Safari bug" in text
|
||||
assert "Fix X" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cli_bootstrap(tmp_path):
|
||||
# Create a minimal project to bootstrap
|
||||
proj = tmp_path / "myproj"
|
||||
proj.mkdir()
|
||||
(proj / "package.json").write_text(json.dumps({
|
||||
"dependencies": {"vue": "^3.0"},
|
||||
}))
|
||||
src = proj / "src" / "components"
|
||||
src.mkdir(parents=True)
|
||||
(src / "App.vue").write_text("<template></template>")
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [
|
||||
"--db", str(db_path),
|
||||
"bootstrap", str(proj),
|
||||
"--id", "myproj",
|
||||
"--name", "My Project",
|
||||
"--vault", str(tmp_path / "nonexistent_vault"),
|
||||
"-y",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert "vue3" in result.output
|
||||
assert "Saved:" in result.output
|
||||
|
||||
# Verify in DB
|
||||
conn = init_db(db_path)
|
||||
p = models.get_project(conn, "myproj")
|
||||
assert p is not None
|
||||
assert "vue3" in p["tech_stack"]
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_cli_bootstrap_already_exists(tmp_path):
|
||||
proj = tmp_path / "myproj"
|
||||
proj.mkdir()
|
||||
|
||||
db_path = tmp_path / "test.db"
|
||||
runner = CliRunner()
|
||||
# Create project first
|
||||
runner.invoke(cli, ["--db", str(db_path), "project", "add", "myproj", "X", str(proj)])
|
||||
# Try bootstrap — should fail
|
||||
result = runner.invoke(cli, [
|
||||
"--db", str(db_path),
|
||||
"bootstrap", str(proj),
|
||||
"--id", "myproj", "--name", "X", "-y",
|
||||
])
|
||||
assert result.exit_code == 1
|
||||
assert "already exists" in result.output
|
||||
Loading…
Add table
Add a link
Reference in a new issue