diff --git a/backend/main.py b/backend/main.py index 96dd9e2..0e78ec7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -520,19 +520,18 @@ def handle_suggest_clips(task_id: str, params: dict): ) return + errors: list[str] = [] clips = suggest_with_claude( segments=segments, top_n=top_n, exclude_clips=existing_clips, progress_callback=lambda pct, msg: emit_progress(task_id, "suggesting", pct, msg), + error_sink=errors, ) if clips is None: - emit_result( - task_id, - "error", - error="AI CLI found but suggestion failed — check claude/codex login and try again", - ) + detail = errors[0] if errors else "check claude/codex login and try again" + emit_result(task_id, "error", error=detail) return emit_result(task_id, "success", data={"clips": clips}) diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index 3bfcd6f..96d8840 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -875,12 +875,28 @@ def find_moments_from_text( pass +def classify_cli_error(detail: str) -> str: + """Turn a raw AI CLI failure into an actionable hint. The generic + 'check login' message hides whether it's auth, a plan limit, or a crash.""" + low = (detail or "").lower() + if any(s in low for s in ("not logged in", "please run", "/login", "authenticate", "unauthorized", "invalid api key", "no credentials")): + return "not logged in. Run `claude` (or `codex`) once in a terminal to authenticate, then retry." + if any(s in low for s in ("usage limit", "rate limit", "quota", "too many requests", "429")): + return "usage or rate limit reached on your plan. Wait for the limit to reset, then retry." + if "timed out" in low or "timeout" in low: + return detail + if not detail: + return "the AI CLI returned no output. Run `claude` once in a terminal to confirm it responds." + return detail + + def suggest_with_claude( segments: list[dict], top_n: int = 5, exclude_clips: list[dict] | None = None, progress_callback: Optional[Callable[[int, str], None]] = None, timeout: int = 300, + error_sink: Optional[list[str]] = None, ) -> Optional[list[dict]]: """ Use an AI CLI (Claude Code or Codex) to extract the best clip moments. @@ -939,6 +955,7 @@ def _parse_seconds(val) -> float: except ValueError: return 0.0 + last_detail: Optional[str] = None for idx, (cli_path, engine) in enumerate(candidates): label = _engine_label(engine) if idx > 0 and progress_callback: @@ -955,17 +972,20 @@ def _parse_seconds(val) -> float: timeout=timeout, ) except subprocess.TimeoutExpired: + last_detail = f"{label} timed out ({_format_timeout_label(timeout)} limit)" if progress_callback: - progress_callback(0, f"{label} timed out ({_format_timeout_label(timeout)} limit)") + progress_callback(0, last_detail) continue except Exception as e: + last_detail = f"{label} error: {e}" if progress_callback: - progress_callback(0, f"{label} error: {e}") + progress_callback(0, last_detail) continue if result.returncode != 0 or not result.stdout.strip(): + detail = (result.stderr or "no response").strip()[:200] + last_detail = f"{label}: {detail}" if progress_callback: - detail = (result.stderr or "no response").strip()[:200] progress_callback(0, f"{label} returned error: {detail}") continue @@ -987,12 +1007,14 @@ def _parse_seconds(val) -> float: else: data = json.loads(response) except json.JSONDecodeError as e: + last_detail = f"{label} returned output that wasn't valid JSON ({e})" if progress_callback: progress_callback(0, f"Could not parse {label}'s response as JSON: {e}") continue clips = data.get("clips", []) if not clips: + last_detail = f"{label} ran but found no clips in the transcript" if progress_callback: progress_callback(0, f"{label} returned no clips") continue @@ -1047,9 +1069,12 @@ def _parse_seconds(val) -> float: progress_callback(100, f"{label} suggested {len(selected)} clips") return selected + last_detail = f"{label} returned clips but none were usable (wrong length or format)" if progress_callback: progress_callback(0, f"{label} returned no usable clips") + if error_sink is not None: + error_sink.append(classify_cli_error(last_detail or "")) return None finally: # Clean up temp file diff --git a/cli/internal/provision/provision.go b/cli/internal/provision/provision.go index ec33d11..ba1aa8e 100644 --- a/cli/internal/provision/provision.go +++ b/cli/internal/provision/provision.go @@ -711,14 +711,40 @@ func EnsurePython(requirements string) (string, error) { } } if requirements != "" { - if err := pipInstall(bin, requirements); err != nil { + if err := ensureDeps(bin, requirements); err != nil { return "", err } } return bin, nil } +func ensureDeps(pybin, requirements string) error { + sum, err := sha256file(requirements) + if err == nil && sum != "" && depsInstalled(pybin, sum) { + return nil + } + if err := pipInstall(pybin, requirements); err != nil { + return err + } + if sum != "" { + os.WriteFile(depsStamp(pybin), []byte(sum), 0o644) + } + return nil +} + +func depsStamp(pybin string) string { + return filepath.Join(pythonRoot(pybin), ".podcli-deps") +} + +func depsInstalled(pybin, sum string) bool { + b, err := os.ReadFile(depsStamp(pybin)) + return err == nil && strings.TrimSpace(string(b)) == sum +} + func pipInstall(pybin, requirements string) error { + if err := ensurePip(pybin); err != nil { + return err + } fmt.Fprintf(os.Stderr, " installing python deps (%s) - pulls ~80MB, first run takes a minute\n", filepath.Base(requirements)) cmd := exec.Command(pybin, "-m", "pip", "install", "--disable-pip-version-check", "--progress-bar=on", "-r", requirements) cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr @@ -726,6 +752,15 @@ func pipInstall(pybin, requirements string) error { return cmd.Run() } +func ensurePip(pybin string) error { + if exec.Command(pybin, "-m", "pip", "--version").Run() == nil { + return nil + } + cmd := exec.Command(pybin, "-m", "ensurepip", "--upgrade") + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr + return cmd.Run() +} + // EnsureSpeakerDeps installs the speaker-diarization stack (pyannote.audio pulls // torch) into the hermetic Python. Opt-in because it's a large download (~2GB) // most users don't need. diff --git a/cli/main.go b/cli/main.go index 6521283..0f97538 100644 --- a/cli/main.go +++ b/cli/main.go @@ -80,18 +80,39 @@ func wantsRuntime(args []string) bool { // ensureRuntime self-provisions on first run so `podcli` works without a separate // `podcli setup`. Not called on the mcp path, whose stdout is the JSON-RPC channel. -func ensureRuntime() { - if _, ok := engine.BackendRoot(); ok { - return +func ensureRuntime() error { + if _, ok := engine.BackendRoot(); !ok { + fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...") + setup(nil) } - fmt.Fprintln(os.Stderr, "First run - setting up podcli (one-time download)...") - setup(nil) + return ensureBackendDeps() +} + +func ensureBackendDeps() error { + if !engine.IsHermeticPython() { + return nil + } + root, ok := engine.BackendRoot() + if !ok { + return nil + } + reqs := filepath.Join(root, "requirements-runtime.txt") + if _, err := os.Stat(reqs); err != nil { + return nil + } + if _, err := provision.EnsurePython(reqs); err != nil { + return fmt.Errorf("could not install Python dependencies: %w\n run `podcli setup` to retry", err) + } + return nil } func runEngine(args []string) int { update.NotifyIfOutdated(Version) if wantsRuntime(args) { - ensureRuntime() + if err := ensureRuntime(); err != nil { + fmt.Fprintln(os.Stderr, "podcli:", err) + return 1 + } } if transcribeEngine(args) == "whispercpp" { model, err := provision.EnsureModel(transcribeModel(args)) diff --git a/package-lock.json b/package-lock.json index 6cb3f10..4f3edda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "podcli", - "version": "2.2.1", + "version": "2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "podcli", - "version": "2.2.1", + "version": "2.3.3", "license": "AGPL-3.0-only", "dependencies": { "@fontsource/dm-sans": "^5.2.8", diff --git a/package.json b/package.json index 8a26d59..0b79e58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "podcli", - "version": "2.3.1", + "version": "2.3.3", "description": "AI-powered podcast clip generator for TikTok/YouTube Shorts. Transcribe, find viral moments, export vertical clips with burned captions.", "type": "module", "license": "AGPL-3.0-only",