Skip to content
Closed
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
184 changes: 167 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,10 @@ 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
_providers = ai_provider.status()["providers"]

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

Expand All @@ -3504,8 +3506,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 +3635,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.")
Comment on lines +3677 to +3684

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

logout reports success while a PODCLI_TOKEN session stays active.

podcli_cloud.read_token() returns PODCLI_TOKEN before it reads the file. clear_token() only unlinks the file. If the user set PODCLI_TOKEN, signed_in() stays True after logout and the printed message is wrong. Warn in that case.

🐛 Proposed fix
     if not podcli_cloud.signed_in():
         print("Not signed in.")
         return
     podcli_cloud.clear_token()
+    if os.environ.get("PODCLI_TOKEN", "").strip():
+        print("PODCLI_TOKEN is still set in this environment — unset it to fully sign out.")
+        return
     print("Signed out. podcli will use your local AI CLI from now on.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 3677 - 3684, Update cmd_logout to detect an
active PODCLI_TOKEN session before calling podcli_cloud.clear_token(), warn the
user that logout cannot clear the environment-provided token, and avoid printing
the successful “Signed out” message in that case; preserve the existing
file-token logout behavior.



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']})")
Comment on lines +3721 to +3746

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the workspace payload contract on both clients.
fd -t f 'podcli.cloud' -x rg -n -C4 'workspaces|workspaceId|switch_workspace|switchWorkspace|create_workspace|createWorkspace' {}

Repository: nmbrthirteen/podcli

Length of output: 1652


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)cli\.py$|(^|/)podcli\.cloud$' || true

echo "== backend/cli.py relevant sections =="
if [ -f backend/cli.py ]; then
  wc -l backend/cli.py
  sed -n '3680,3770p' backend/cli.py | cat -n
  echo "-- Workspace command area --"
  rg -n -C 8 'def cmd_workspace|workspace_action|_warn_local_data|create_workspace|switch_workspace|list_workspaces' backend/cli.py
fi

echo "== podcli.cloud workspace related section =="
if [ -f backend/cli/cloud/podcli.cloud ] || [ -f backend/cli/podcli.cloud ]; then
  file=$(git ls-files | rg '/podcli\.cloud$' | head -n1)
  sed -n '1,400p' "$file" | cat -n | sed -n '240,330p'
fi

echo "== workspace TypeScript related section =="
for f in $(git ls-files | rg 'podcli.cloud$'); do
  echo "--- $f ---"
  rg -n -C 6 'workspaceId|plan|workspaces|createWorkspace|switchWorkspace' "$f"
done

Repository: nmbrthirteen/podcli

Length of output: 7829


Guard optional workspace display fields against KeyError.

cmd_workspace() uses raw payload fields in print() and w["name"].lower(), but the workspace payload contract only guarantees token/workspace identifiers. A missing display field raises KeyError, and except podcli_cloud.CloudError does not catch it. Use .get() with safe defaults for display fields, such as created.get("name", args.name), switched.get("name", target.get("name", "workspace")), and switched.get("plan", "unknown").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cli.py` around lines 3721 - 3746, Update cmd_workspace to avoid
direct indexing of optional workspace display fields: use safe defaults for
created and switched names and plans, and use .get() with fallback values when
matching and listing workspaces instead of w["name"].lower() or other raw
display-field access. Preserve identifier-based matching and the existing
command output behavior.

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 +3952,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 +4302,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
31 changes: 22 additions & 9 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
Comment on lines +693 to 704

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use provider-neutral failure guidance after unified provider selection.

Both handlers can now use cloud, CLI, or direct API providers. Do not report only Claude or Codex recovery steps.

  • backend/main.py#L693-L704: when suggestion generation fails, return the provider error or provider-neutral guidance.
  • backend/main.py#L796-L807: when content generation fails, do not state that an AI CLI was found.
📍 Affects 1 file
  • backend/main.py#L693-L704 (this comment)
  • backend/main.py#L796-L807
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/main.py` around lines 693 - 704, Update the failure handling in
backend/main.py lines 693-704 within the suggestion-generation handler to return
the selected provider’s error or provider-neutral guidance instead of naming
Claude, Codex, or a specific CLI. Apply the same change at backend/main.py lines
796-807 in the content-generation handler, removing the claim that an AI CLI was
found and preserving the existing error result flow.

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
Expand Down Expand Up @@ -927,6 +939,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
Loading