Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
31 changes: 28 additions & 3 deletions backend/services/claude_suggest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion cli/internal/provision/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,21 +711,56 @@ 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
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
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.
Expand Down
33 changes: 27 additions & 6 deletions cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading