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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ tmp*.txt
# Episodes (generated content packages)
episodes/

# Planning notes
plans/

# Media & temp files
*.mp4
*.mp3
Expand Down
189 changes: 172 additions & 17 deletions backend/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,15 +920,15 @@ def _transcribe_progress(pct, msg):
print(" ⚠ No highlights found, falling back to transcript selection")

# Try an AI CLI first (uses PodStack knowledge base for intelligent selection)
from services.claude_suggest import (
suggest_initial_with_claude, blend_signal_scores, _engine_label, _find_ai_cli,
)
from services import ai_provider
from services.ai_cli import _engine_label
from services.claude_suggest import blend_signal_scores, suggest_initial_with_claude

ai_path, ai_engine = _find_ai_cli()
providers = ai_provider.status()["providers"]
if clips:
pass # already selected (resumed cache or saliency profile)
elif ai_path and config.get("ai_select", True):
ai_label = _engine_label(ai_engine)
elif providers and config.get("ai_select", True):
ai_label = providers[0]["label"]
print(f" [3/4] Selecting moments with {ai_label} (PodStack)...")
clips = suggest_initial_with_claude(
segments=segments,
Expand All @@ -938,8 +938,9 @@ def _transcribe_progress(pct, msg):
)
if clips:
blend_signal_scores(clips, energy_data=energy_data, events_data=events_data)
actual_engine = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), ai_engine)
print(f" ✓ {_engine_label(actual_engine)} selected {len(clips)} clips")
engine_id = next((c.get("_ai_engine") for c in clips if c.get("_ai_engine")), "")
actual_engine = _engine_label(engine_id) if engine_id in ("claude", "codex") else ai_label
print(f" ✓ {actual_engine} selected {len(clips)} clips")
_save_suggestions_session(cache_hash, top_n, actual_engine, clips, selection_sig)
else:
print(" ⚠ AI CLI unavailable, falling back to heuristics")
Expand Down Expand Up @@ -1006,9 +1007,9 @@ def _transcribe_progress(pct, msg):
pass
_thumb_intro_duration = max(0.5, min(_thumb_intro_duration, 1.0))

# Check if AI CLI is available for per-clip content generation
from services.claude_suggest import _find_ai_cli
_ai_cli_path, _ = _find_ai_cli()
# Per-clip content generation needs any provider, not specifically a binary.
from services import ai_provider
_ai_cli_path = "cloud" if ai_provider.available() else None

# Pre-load thumbnail tools if enabled
_thumb_gen = None
Expand Down Expand Up @@ -3491,9 +3492,15 @@ def print_banner():
_diarization_ok = False
speakers_ok = bool(hf_token) and _diarization_ok

# Check AI CLI (Claude Code or Codex)
from services.claude_suggest import _find_ai_cli
ai_path, ai_engine = _find_ai_cli()
# `info` should report what AI podcli will actually use, which for a
# signed-in user is the workspace rather than any local binary.
from services import ai_provider
# Every other lookup in this banner is guarded. This one reads and parses
# the local auth file, so a truncated one would take `podcli info` with it.
try:
_providers = ai_provider.status()["providers"]
except Exception:
_providers = []

print(f" {bold}podcli{reset} v{VERSION}")

Expand All @@ -3504,8 +3511,8 @@ def print_banner():
cache_count = len([f for f in os.listdir(cache_dir) if f.endswith(".json")])

# Status — one line
ai_label = ("Claude" if ai_engine == "claude" else "Codex") if ai_path else "AI CLI"
ai_tag = f"{green}✓ {ai_label}{reset}" if ai_path else f"{yellow}✗{reset}"
ai_label = _providers[0]["label"] if _providers else "AI"
ai_tag = f"{green}✓ {ai_label}{reset}" if _providers else f"{yellow}✗{reset}"
speaker_tag = f"{green}✓{reset}" if speakers_ok else f"{yellow}✗{reset}"
cache_tag = f"{green}{cache_count}{reset}" if cache_count else f"{gray}0{reset}"
kb_tag = f"{green}{kb_count}{reset}" if kb_count else f"{yellow}0{reset}"
Expand Down Expand Up @@ -3633,6 +3640,132 @@ def print_help():
print()


def cmd_login(args):
import getpass
from services import podcli_cloud

email = (args.email or input("Email: ")).strip()
# Prefer the prompt: a password in argv is visible in ps output and lands in
# the user's shell history.
password = args.password or getpass.getpass("Password: ")
if not email or not password:
print("Email and password are required.")
sys.exit(1)

try:
podcli_cloud.login(email, password)
account = podcli_cloud.me()
podcli_cloud.remember_plan(account.get("plan", ""))
except podcli_cloud.CloudError as exc:
print(f"Sign-in failed: {exc}")
sys.exit(1)

workspace = account.get("workspace") or {}
print(f"Signed in to {workspace.get('name', 'your workspace')} "
f"({account.get('plan', 'free')} plan, {account.get('role', 'member')}).")
if account.get("plan") == "free":
print("This workspace has no active subscription — podcli will keep using "
"your local AI CLI until one starts.")

# Everything already rendered on this machine belongs in the workspace too,
# so the performance model starts with a back catalogue instead of nothing.
try:
synced, failed = podcli_cloud.backfill_clips()
except Exception:
synced, failed = 0, 0
if synced:
print(f"Synced {synced} existing clip{'s' if synced != 1 else ''} to your workspace.")
if failed:
print(f"{failed} could not be synced — `podcli whoami` will retry later.")


def cmd_logout(args):
from services import podcli_cloud

if not podcli_cloud.signed_in():
print("Not signed in.")
return
podcli_cloud.clear_token()
print("Signed out. podcli will use your local AI CLI from now on.")


def cmd_whoami(args):
from services import ai_provider, podcli_cloud

if not podcli_cloud.signed_in():
print("Not signed in to podcli Pro. Run `podcli login`.")
else:
try:
account = podcli_cloud.me()
podcli_cloud.remember_plan(account.get("plan", ""))
workspace = account.get("workspace") or {}
print(f"Signed in to {workspace.get('name', '?')} "
f"({account.get('plan')} plan, {account.get('role')})")
used = workspace.get("episodes_used")
if used is not None:
print(f"Episodes used this month: {used}")
except podcli_cloud.CloudError as exc:
print(f"Signed in, but the account could not be checked: {exc}")

providers = ai_provider.status()["providers"]
if providers:
print("AI will use: " + " → ".join(p["label"] for p in providers))
else:
print("No AI available. Install Claude Code, set ANTHROPIC_API_KEY, or sign in.")


def cmd_workspace(args):
from services import podcli_cloud

if not podcli_cloud.signed_in():
print("Not signed in to podcli Pro. Run `podcli login`.")
sys.exit(1)

action = getattr(args, "workspace_action", None) or "list"
try:
if action == "new":
created = podcli_cloud.create_workspace(args.name)
print(f"Created {created['name']} and switched to it (free plan).")
print("Each show carries its own subscription, so this one needs its own.")
_warn_local_data()
return

workspaces = podcli_cloud.list_workspaces()

if action == "use":
target = next(
(w for w in workspaces
if args.name.lower() in (w["name"].lower(), w["id"].lower())),
None,
)
if not target:
print(f"No workspace matching {args.name!r}.")
sys.exit(1)
switched = podcli_cloud.switch_workspace(target["id"])
print(f"Switched to {switched['name']} ({switched['plan']} plan).")
_warn_local_data()
return

for w in workspaces:
marker = "*" if w.get("current") else " "
print(f" {marker} {w['name']} ({w['plan']}, {w['role']})")
except podcli_cloud.CloudError as exc:
print(f"Could not reach podcli Pro: {exc}")
sys.exit(1)


def _warn_local_data():
"""Switching workspace does not move the local knowledge base or assets.

Those live in .podcli/ on this machine, and a second show's brand voice
overwriting the first is data loss rather than a sync. Keeping each show in
its own directory (or PODCLI_HOME) is the honest answer until profiles do it
automatically.
"""
print()
print(" Local .podcli/ data is per-directory, not per-workspace.")
print(" Work on each show from its own folder so their knowledge bases "
"and assets stay separate.")
def _onboarding_marker() -> str:
return os.path.join(paths["home"], ".onboarded")

Expand Down Expand Up @@ -3824,6 +3957,20 @@ def main():
parser.add_argument("--no-banner", action="store_true", help=argparse.SUPPRESS)
sub = parser.add_subparsers(dest="command")

# ── podcli Pro account ──
login_p = sub.add_parser("login", help="Sign in to podcli Pro")
login_p.add_argument("--email", help="Account email (prompted if omitted)")
login_p.add_argument("--password", help="Password (prompted if omitted; prefer the prompt)")
sub.add_parser("logout", help="Sign out of podcli Pro on this machine")
sub.add_parser("whoami", help="Show the signed-in podcli Pro account")
ws_p = sub.add_parser("workspace", help="Switch between shows in podcli Pro")
ws_sub = ws_p.add_subparsers(dest="workspace_action")
ws_sub.add_parser("list", help="List your workspaces")
ws_new = ws_sub.add_parser("new", help="Create a workspace for another show")
ws_new.add_argument("name", help="Workspace name")
ws_use = ws_sub.add_parser("use", help="Switch to a workspace")
ws_use.add_argument("name", help="Workspace name or id")

# ── process ──
proc = sub.add_parser("process", help="Process a video into clips")
proc.add_argument("video", nargs="?", default=None, help="Path to podcast video file (optional if preset has video_path)")
Expand Down Expand Up @@ -4160,7 +4307,15 @@ def main():
print(" Setup cancelled. Your command did not run.", file=sys.stderr)
sys.exit(130)

if args.command == "process":
if args.command == "login":
cmd_login(args)
elif args.command == "logout":
cmd_logout(args)
elif args.command == "whoami":
cmd_whoami(args)
elif args.command == "workspace":
cmd_workspace(args)
elif args.command == "process":
if not getattr(args, "no_banner", False):
print()
cmd_process(args)
Expand Down
38 changes: 27 additions & 11 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,8 +676,8 @@ def _signal_profiles_for_suggest(

def handle_suggest_clips(task_id: str, params: dict):
"""AI-powered clip suggestion using Claude/Codex and PodStack knowledge base."""
from services import ai_provider
from services.claude_suggest import (
_find_ai_cli_candidates,
select_clips_with_signal_scores,
suggest_initial_with_claude,
)
Expand All @@ -690,13 +690,15 @@ def handle_suggest_clips(task_id: str, params: dict):
emit_result(task_id, "error", error="segments is required")
return

if not _find_ai_cli_candidates():
# Gate on the provider chain, not on a local binary: a signed-in Pro user
# has AI available without installing anything.
if not ai_provider.available():
emit_result(
task_id,
"error",
error=(
"No AI CLI available (install Claude Code or Codex). "
"If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH."
"No AI available. Sign in with `podcli login`, install Claude Code "
"or Codex, or set ANTHROPIC_API_KEY."
),
)
return
Expand Down Expand Up @@ -740,11 +742,19 @@ def handle_manage_env(task_id: str, params: dict):


def handle_ai_cli_status(task_id: str, params: dict):
from services.claude_suggest import get_ai_cli_status
from services.ai_cli import get_ai_cli_status

emit_result(task_id, "success", data=get_ai_cli_status())


def handle_ai_provider_status(task_id: str, params: dict):
"""Everything podcli can use for AI, not just local binaries — so the studio
can tell "nothing installed" apart from "signed in, nothing needed"."""
from services import ai_provider

emit_result(task_id, "success", data=ai_provider.status())


def handle_find_moment(task_id: str, params: dict):
"""Locate user-pasted/described moments in the transcript via the AI CLI."""
from services.claude_suggest import find_moments_from_text
Expand Down Expand Up @@ -774,7 +784,7 @@ def handle_find_moment(task_id: str, params: dict):
def handle_generate_content(task_id: str, params: dict):
"""Generate titles, descriptions, tags for a clip using PodStack knowledge base."""
from services.content_generator import generate_clip_content
from services.claude_suggest import _find_ai_cli_candidates
from services import ai_provider

clip = params.get("clip", {})
transcript_segments = params.get("transcript_segments", [])
Expand All @@ -783,13 +793,15 @@ def handle_generate_content(task_id: str, params: dict):
emit_result(task_id, "error", error="clip is required")
return

if not _find_ai_cli_candidates():
# Gate on the provider chain, not on a local binary: a signed-in Pro user
# has AI available without installing anything.
if not ai_provider.available():
emit_result(
task_id,
"error",
error=(
"No AI CLI available (install Claude Code or Codex). "
"If already installed, set the path in Config → AI CLI or PODCLI_CLAUDE_PATH."
"No AI available. Sign in with `podcli login`, install Claude Code "
"or Codex, or set ANTHROPIC_API_KEY."
),
)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand All @@ -808,7 +820,7 @@ def handle_generate_content(task_id: str, params: dict):
emit_result(
task_id,
"error",
error="AI CLI found but content generation failed — check claude/codex login and try again",
error="Content generation failed — check that your AI provider is reachable and try again",
)
return

Expand All @@ -832,7 +844,10 @@ def handle_generate_custom(task_id: str, params: dict):
)

if result is None:
emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)")
# The gate above admits a workspace session or an API key as well as a
# local binary, so naming only the binary sends cloud users to install
# something they will never use.
emit_result(task_id, "error", error="No AI provider available — sign in to podcli Pro, install Claude Code or Codex, or set ANTHROPIC_API_KEY")
return

emit_result(task_id, "success", data=result)
Expand Down Expand Up @@ -927,6 +942,7 @@ def handle_run_integration_tool(task_id: str, params: dict):
"find_moment": handle_find_moment,
"manage_env": handle_manage_env,
"ai_cli_status": handle_ai_cli_status,
"ai_provider_status": handle_ai_provider_status,
"generate_content": handle_generate_content,
"generate_custom": handle_generate_custom,
"manage_integrations": handle_manage_integrations,
Expand Down
Loading