kin: KIN-083 Healthcheck claude CLI auth: перед запуском pipeline проверять что claude залогинен (быстрый claude -p 'ok' --output-format json, проверить is_error и 'Not logged in'). Если не залогинен — не запускать pipeline, а показать ошибку 'Claude CLI requires login' в GUI с инструкцией.

This commit is contained in:
Gros Frumos 2026-03-16 15:48:09 +02:00
parent a80679ae72
commit bfc8f1c0bb
18 changed files with 1390 additions and 57 deletions

View file

@ -213,7 +213,7 @@ def detect_modules(project_path: Path) -> list[dict]:
if not child.is_dir() or child.name in _SKIP_DIRS or child.name.startswith("."):
continue
mod = _analyze_module(child, project_path)
key = (mod["name"], mod["path"])
key = mod["name"]
if key not in seen:
seen.add(key)
modules.append(mod)

View file

@ -68,6 +68,54 @@ from core.context_builder import build_context, format_prompt
from core.hooks import run_hooks
class ClaudeAuthError(Exception):
"""Raised when Claude CLI is not authenticated or not available."""
pass
def check_claude_auth(timeout: int = 10) -> None:
"""Check that claude CLI is authenticated before running a pipeline.
Runs: claude -p 'ok' --output-format json --no-verbose with timeout.
Returns None if auth is confirmed.
Raises ClaudeAuthError if:
- claude CLI not found in PATH (FileNotFoundError)
- stdout/stderr contains 'not logged in' (case-insensitive)
- returncode != 0
- is_error=true in parsed JSON output
Returns silently on TimeoutExpired (ambiguous don't block pipeline).
"""
claude_cmd = _resolve_claude_cmd()
env = _build_claude_env()
try:
proc = subprocess.run(
[claude_cmd, "-p", "ok", "--output-format", "json", "--no-verbose"],
capture_output=True,
text=True,
timeout=timeout,
env=env,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
raise ClaudeAuthError("claude CLI not found in PATH. Install it or add to PATH.")
except subprocess.TimeoutExpired:
return # Ambiguous — don't block pipeline on timeout
stdout = proc.stdout or ""
stderr = proc.stderr or ""
combined = stdout + stderr
if "not logged in" in combined.lower():
raise ClaudeAuthError("Claude CLI requires login. Run: claude login")
if proc.returncode != 0:
raise ClaudeAuthError("Claude CLI requires login. Run: claude login")
parsed = _try_parse_json(stdout)
if isinstance(parsed, dict) and parsed.get("is_error"):
raise ClaudeAuthError("Claude CLI requires login. Run: claude login")
def run_agent(
conn: sqlite3.Connection,
role: str,
@ -467,6 +515,37 @@ def _is_permission_error(result: dict) -> bool:
# Autocommit: git add -A && git commit after successful pipeline
# ---------------------------------------------------------------------------
def _get_changed_files(project_path: str) -> list[str]:
"""Return files changed in the current pipeline run.
Combines unstaged changes, staged changes, and the last commit diff
to cover both autocommit-on and autocommit-off scenarios.
Returns [] on any git error (e.g. no git repo, first commit).
"""
env = _build_claude_env()
git_cmd = shutil.which("git", path=env["PATH"]) or "git"
files: set[str] = set()
for git_args in (
["diff", "--name-only"], # unstaged tracked changes
["diff", "--cached", "--name-only"], # staged changes
["diff", "HEAD~1", "HEAD", "--name-only"], # last commit (post-autocommit)
):
try:
r = subprocess.run(
[git_cmd] + git_args,
cwd=project_path,
capture_output=True,
text=True,
timeout=10,
env=env,
)
if r.returncode == 0:
files.update(f.strip() for f in r.stdout.splitlines() if f.strip())
except Exception:
pass
return list(files)
def _run_autocommit(
conn: sqlite3.Connection,
task_id: str,
@ -582,7 +661,7 @@ def _save_sysadmin_output(
if not m_name:
continue
try:
models.add_module(
m = models.add_module(
conn,
project_id=project_id,
name=m_name,
@ -591,7 +670,10 @@ def _save_sysadmin_output(
description=item.get("description"),
owner_role="sysadmin",
)
modules_added += 1
if m.get("_created", True):
modules_added += 1
else:
modules_skipped += 1
except Exception:
modules_skipped += 1
@ -739,6 +821,18 @@ def run_pipeline(
Returns {success, steps_completed, total_cost, total_tokens, total_duration, results}
"""
# Auth check — skip for dry_run (dry_run never calls claude CLI)
if not dry_run:
try:
check_claude_auth()
except ClaudeAuthError as exc:
return {
"success": False,
"error": "claude_auth_required",
"message": str(exc),
"instructions": "Run: claude login",
}
task = models.get_task(conn, task_id)
if not task:
return {"success": False, "error": f"Task '{task_id}' not found"}
@ -979,6 +1073,14 @@ def run_pipeline(
task_modules = models.get_modules(conn, project_id)
# Compute changed files for hook filtering (frontend build trigger)
changed_files: list[str] | None = None
project = models.get_project(conn, project_id)
if project and project.get("path"):
p_path = Path(project["path"]).expanduser()
if p_path.is_dir():
changed_files = _get_changed_files(str(p_path))
last_role = steps[-1].get("role", "") if steps else ""
auto_eligible = last_role in {"tester", "reviewer"}
@ -1018,7 +1120,8 @@ def run_pipeline(
# Run post-pipeline hooks (failures don't affect pipeline status)
try:
run_hooks(conn, project_id, task_id,
event="pipeline_completed", task_modules=task_modules)
event="pipeline_completed", task_modules=task_modules,
changed_files=changed_files)
except Exception:
pass # Hook errors must never block pipeline completion