From dd003745c0e680f0cd4bf4a18e2a39a08c4fb01c Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 6 Jul 2026 16:02:04 +0400 Subject: [PATCH 1/3] Surface the real AI CLI failure instead of a generic login hint Clip suggestion caught the actual failure reason (Claude/Codex stderr, a timeout, or unparseable output) but only sent it to transient progress and returned None, so the user always saw 'check claude/codex login' regardless of the real cause. Capture the last failure detail through an error_sink and classify it (auth vs usage limit vs crash) so the message names what actually went wrong. --- backend/main.py | 9 ++++----- backend/services/claude_suggest.py | 31 +++++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) 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 From a0d315c85879373d6ce0d826a6b30e46f65b4ee1 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 6 Jul 2026 18:13:47 +0400 Subject: [PATCH 2/3] Ensure hermetic Python deps on every launch, not just first run A failed or partial pip install during first-run setup left the backend permanently unable to import its deps (e.g. questionary), since ensureRuntime gated on the backend tree existing rather than the deps being present. Verify deps on every runtime-wanting launch via a requirements-hash stamp so a broken provision self-heals, bootstrap pip via ensurepip when absent, and surface an actionable error instead of crashing Python on the missing import. --- cli/internal/provision/provision.go | 37 ++++++++++++++++++++++++++++- cli/main.go | 33 ++++++++++++++++++++----- 2 files changed, 63 insertions(+), 7 deletions(-) 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)) From f1d73ca358887613a4369672a6d4342ce7fd01b9 Mon Sep 17 00:00:00 2001 From: Nika Siradze Date: Mon, 6 Jul 2026 18:20:21 +0400 Subject: [PATCH 3/3] Release v2.3.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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",