diff --git a/backend/cli.py b/backend/cli.py index 2ec78b1..8101da5 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -2874,17 +2874,24 @@ def fail(e): def cmd_env(args): """Manage secrets/settings in the global .env.""" + from services.claude_suggest import get_ai_cli_status from services.env_settings import run_env_action accent = "\033[38;2;212;135;74m" green = "\033[38;2;74;222;128m" gray = "\033[38;5;245m" + yellow = "\033[38;2;250;204;21m" reset = "\033[0m" action = getattr(args, "env_action", None) or "list" try: if action == "set": run_env_action("set", args.key, args.value) print(f" {green}✓{reset} {args.key} set") + if args.key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"): + status = get_ai_cli_status() + if status.get("available"): + for c in status.get("candidates", []): + print(f" {gray}→{reset} {c['engine']}: {c['path']}") elif action == "unset": run_env_action("unset", args.key) print(f" {green}✓{reset} {args.key} removed") @@ -2892,11 +2899,22 @@ def cmd_env(args): data = run_env_action("list") print(f"\n {gray}Settings ({data['path']}){reset}\n") for s in data["settings"]: - mark = f"{green}set{reset}" if s["set"] else f"{gray}not set{reset}" + mark = f"{green}set{reset}" if s["set"] else f"{gray}auto{reset}" if not s["secret"] else f"{gray}not set{reset}" val = f" {gray}{s['preview']}{reset}" if s["set"] else "" print(f" {accent}{s['key']}{reset} — {s['label']} [{mark}]{val}") print(f" {gray}{s['help']}{reset}") - print(f" {gray}{s['url']}{reset}\n") + if s.get("url"): + print(f" {gray}{s['url']}{reset}") + print() + ai = data.get("ai_cli") or {} + if ai.get("available"): + print(f" {green}AI CLI detected{reset}") + for c in ai.get("candidates", []): + print(f" {accent}{c['engine']}{reset} {c['path']}") + else: + print(f" {yellow}AI CLI not detected{reset}") + print(f" {gray}Set a path:{reset} {accent}podcli env set PODCLI_CLAUDE_PATH ~/.local/bin/claude{reset}") + print() except ValueError as e: print(f" ✗ {e}", file=sys.stderr) sys.exit(1) @@ -3008,15 +3026,18 @@ def cmd_cache(args): def cmd_info(args): """Show system info.""" from services.encoder import get_encoder_info - from services.claude_suggest import _find_ai_cli + from services.claude_suggest import get_ai_cli_status green = "\033[38;2;74;222;128m" yellow = "\033[38;2;250;204;21m" gray = "\033[38;5;245m" + accent = "\033[38;2;212;135;74m" reset = "\033[0m" info = get_encoder_info() - ai_path, ai_engine = _find_ai_cli() + ai = get_ai_cli_status() + candidates = ai.get("candidates") or [] + configured = ai.get("configured") or {} # Check HF_TOKEN hf_token = os.environ.get("HF_TOKEN", "") @@ -3048,7 +3069,18 @@ def cmd_info(args): else: speakers_status = f"{yellow}✗ set a token — run: podcli env set HF_TOKEN " - print(f" AI CLI: {green}{('Claude' if ai_engine == 'claude' else 'Codex') + ' (' + ai_path + ')' if ai_path else f'{yellow}not found — install Claude Code or Codex'}{reset}") + if candidates: + c0 = candidates[0] + ai_line = f"{green}{('Claude' if c0['engine'] == 'claude' else 'Codex')} ({c0['path']}){reset}" + else: + ai_line = f"{yellow}not found{reset}" + print(f" AI CLI: {ai_line}") + for engine in ("claude", "codex"): + manual = configured.get(engine) + if manual: + print(f" {engine} override: {accent}{manual}{reset}") + if not candidates: + print(f" {gray}Override:{reset} {accent}podcli env set PODCLI_CLAUDE_PATH ~/.local/bin/claude{reset}") print(f" Speakers: {speakers_status}{reset}") print() @@ -3496,11 +3528,11 @@ def main(): cfg_use.add_argument("home", help="Path to the config root to activate") # ── env (secrets / settings) ── - env_p = sub.add_parser("env", help="Manage secrets/settings stored in .env (e.g. HF_TOKEN)") + env_p = sub.add_parser("env", help="Manage .env settings (HF_TOKEN, PODCLI_CLAUDE_PATH, PODCLI_CODEX_PATH)") env_sub = env_p.add_subparsers(dest="env_action") env_sub.add_parser("list", help="Show known settings and whether they're set") env_set = env_sub.add_parser("set", help="Set a setting") - env_set.add_argument("key", help="Setting key, e.g. HF_TOKEN") + env_set.add_argument("key", help="Setting key, e.g. HF_TOKEN or PODCLI_CLAUDE_PATH") env_set.add_argument("value", help="Value") env_unset = env_sub.add_parser("unset", help="Remove a setting") env_unset.add_argument("key", help="Setting key, e.g. HF_TOKEN") diff --git a/backend/main.py b/backend/main.py index d07e803..727fcc7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -355,7 +355,7 @@ def handle_corrections(task_id: str, params: dict): def handle_suggest_clips(task_id: str, params: dict): """AI-powered clip suggestion using Claude/Codex and PodStack knowledge base.""" - from services.claude_suggest import suggest_with_claude + from services.claude_suggest import suggest_with_claude, _find_ai_cli_candidates segments = params.get("segments", []) top_n = params.get("top_n", 5) @@ -364,6 +364,17 @@ 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(): + 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." + ), + ) + return + clips = suggest_with_claude( segments=segments, top_n=top_n, @@ -371,7 +382,11 @@ def handle_suggest_clips(task_id: str, params: dict): ) if clips is None: - emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + emit_result( + task_id, + "error", + error="AI CLI found but suggestion failed — check claude/codex login and try again", + ) return emit_result(task_id, "success", data={"clips": clips}) @@ -389,6 +404,12 @@ def handle_manage_env(task_id: str, params: dict): emit_result(task_id, "success", data=data) +def handle_ai_cli_status(task_id: str, params: dict): + from services.claude_suggest import get_ai_cli_status + + emit_result(task_id, "success", data=get_ai_cli_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 @@ -418,6 +439,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 clip = params.get("clip", {}) transcript_segments = params.get("transcript_segments", []) @@ -426,6 +448,17 @@ 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(): + 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." + ), + ) + return + result = generate_clip_content( clip=clip, transcript_segments=transcript_segments, @@ -437,7 +470,11 @@ def handle_generate_content(task_id: str, params: dict): ) if result is None: - emit_result(task_id, "error", error="No AI CLI available (install Claude Code or Codex)") + emit_result( + task_id, + "error", + error="AI CLI found but content generation failed — check claude/codex login and try again", + ) return emit_result(task_id, "success", data=result) @@ -529,6 +566,7 @@ def handle_run_integration_tool(task_id: str, params: dict): "suggest_clips": handle_suggest_clips, "find_moment": handle_find_moment, "manage_env": handle_manage_env, + "ai_cli_status": handle_ai_cli_status, "generate_content": handle_generate_content, "manage_integrations": handle_manage_integrations, "run_integration_tool": handle_run_integration_tool, diff --git a/backend/services/claude_suggest.py b/backend/services/claude_suggest.py index e400efc..ad9a5d6 100644 --- a/backend/services/claude_suggest.py +++ b/backend/services/claude_suggest.py @@ -21,45 +21,342 @@ from presets import MIN_CLIP_DURATION, MAX_CLIP_DURATION, TARGET_CLIP_DURATION_MIN, TARGET_CLIP_DURATION_MAX -def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: - """Find a CLI binary by name. Checks extra_paths first, then PATH. - Uses shutil.which (pure Python) to avoid the ~50-100ms cost of spawning - a `which` subprocess — banner renders twice (claude + codex) on startup. - """ - import shutil - # On Windows the binary is claude.exe / claude.cmd (npm shim), so an - # extensionless path never matches on disk — try the usual suffixes. - exts = ["", ".cmd", ".exe", ".bat"] if sys.platform == "win32" else [""] - for path in (extra_paths or []): - for ext in exts: - if os.path.isfile(path + ext): - return path + ext - return shutil.which(name) +def _cli_name_exts() -> list[str]: + if sys.platform == "win32": + return ["", ".cmd", ".exe", ".bat"] + return [""] -def _ai_cli_search_paths(name: str) -> list[str]: - """Common install locations for an AI CLI, in addition to PATH.""" - paths_out = [ - os.path.expanduser(f"~/.local/bin/{name}"), - f"/usr/local/bin/{name}", - f"/opt/homebrew/bin/{name}", +def _resolve_cli_path(path: str) -> Optional[str]: + for ext in _cli_name_exts(): + candidate = path + ext + if os.path.isfile(candidate): + return candidate + return None + + +def _dedupe_dirs(dirs: list[str]) -> list[str]: + seen: set[str] = set() + ordered: list[str] = [] + for directory in dirs: + if not directory: + continue + directory = os.path.expanduser(directory) + if directory in seen: + continue + seen.add(directory) + if os.path.isdir(directory): + ordered.append(directory) + return ordered + + +def _npmrc_prefix_dirs() -> list[str]: + dirs: list[str] = [] + npmrc_paths = [os.path.join(os.path.expanduser("~"), ".npmrc")] + try: + from services.env_settings import _env_path + npmrc_paths.append(os.path.join(os.path.dirname(_env_path()), ".npmrc")) + except Exception: + pass + for npmrc in npmrc_paths: + if not os.path.isfile(npmrc): + continue + try: + with open(npmrc, encoding="utf-8") as f: + for line in f: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith(";"): + continue + if stripped.startswith("prefix="): + prefix = stripped.split("=", 1)[1].strip() + if prefix: + dirs.append(prefix if sys.platform == "win32" else os.path.join(prefix, "bin")) + except Exception: + pass + return dirs + + +def _package_manager_bin_dirs() -> list[str]: + dirs: list[str] = [] + npm_cmds = [ + (["npm", "config", "get", "prefix"], "prefix"), + (["npm", "root", "-g"], "root"), + ] + for args, kind in npm_cmds: + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if not raw: + continue + if kind == "prefix": + dirs.append(raw if sys.platform == "win32" else os.path.join(raw, "bin")) + elif kind == "root": + dirs.append(os.path.join(raw, ".bin")) + else: + dirs.append(raw) + + for args, kind in ( + (["pnpm", "config", "get", "global-bin-dir"], "bin"), + (["pnpm", "bin", "-g"], "bin"), + (["yarn", "global", "bin"], "bin"), + ): + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=2) + except Exception: + continue + if result.returncode != 0: + continue + raw = result.stdout.strip().splitlines()[0].strip() if result.stdout.strip() else "" + if raw: + dirs.append(raw) + + return dirs + + +def _version_manager_bin_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, "bin"), + os.path.join(home, ".asdf", "shims"), + os.path.join(home, ".local", "share", "mise", "shims"), + os.path.join(home, ".local", "share", "rtx", "shims"), + os.path.join(home, ".bun", "bin"), + os.path.join(home, ".cargo", "bin"), + os.path.join(home, "go", "bin"), + os.path.join(home, ".local", "share", "pnpm"), + os.path.join(home, ".claude", "bin"), + ] + + nvm_dir = os.environ.get("NVM_DIR") or os.path.join(home, ".nvm") + try: + import glob + dirs.extend(sorted(glob.glob(os.path.join(nvm_dir, "versions", "node", "*", "bin")), reverse=True)) + dirs.extend(glob.glob(os.path.join(home, ".fnm", "node-versions", "*", "installation", "bin"))) + dirs.extend(glob.glob(os.path.join(home, ".local", "share", "fnm", "node-versions", "*", "installation", "bin"))) + except Exception: + pass + + fnm_bin = os.path.join(home, ".local", "share", "fnm", "current", "bin") + dirs.append(fnm_bin) + dirs.append(os.path.join(home, ".volta", "bin")) + + if sys.platform == "win32": + for env_key in ("APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(env_key) + if not base: + continue + dirs.extend([ + os.path.join(base, "npm"), + os.path.join(base, "Programs", "nodejs"), + os.path.join(base, "Microsoft", "WinGet", "Links"), + ]) + dirs.append(os.path.join(home, "scoop", "shims")) + dirs.append(os.path.join(os.environ.get("ProgramData", ""), "npm")) + else: + dirs.extend([ + "/usr/bin", + "/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/snap/bin", + "/var/lib/snapd/snap/bin", + ]) + + npm_prefix = ( + os.environ.get("NPM_CONFIG_PREFIX") + or os.environ.get("npm_config_prefix") + or "" + ).strip() + if npm_prefix: + dirs.append(os.path.join(os.path.expanduser(npm_prefix), "bin")) + + return dirs + + +def _static_lookup_dirs() -> list[str]: + home = os.path.expanduser("~") + dirs = [ + os.path.join(home, ".local", "bin"), + os.path.join(home, ".claude", "local", "bin"), + os.path.join(home, ".claude", "local", "node_modules", ".bin"), + os.path.join(home, ".npm-global", "bin"), ] if sys.platform == "win32": appdata = os.environ.get("APPDATA") if appdata: - paths_out.append(os.path.join(appdata, "npm", name)) + dirs.append(os.path.join(appdata, "npm")) + dirs.append(os.path.join(home, ".local", "bin")) + return dirs + + +def _all_lookup_dirs() -> list[str]: + return _dedupe_dirs( + _static_lookup_dirs() + + _version_manager_bin_dirs() + + _npmrc_prefix_dirs() + + _package_manager_bin_dirs() + ) + + +def _path_lookup_dirs() -> list[str]: + return _all_lookup_dirs() + + +def _npm_global_bin_dirs() -> list[str]: + return _package_manager_bin_dirs() + + +def _parse_shell_lookup_line(line: str) -> Optional[str]: + candidate = line.strip().strip('"') + if not candidate: + return None + if " is " in candidate: + candidate = candidate.split(" is ", 1)[1].strip() + if candidate.startswith("(") and candidate.endswith(")"): + candidate = candidate[1:-1].strip() + return _resolve_cli_path(candidate) or (candidate if os.path.isfile(candidate) else None) + + +def _shell_lookup(name: str) -> Optional[str]: + if sys.platform == "win32": + commands = [ + ["where", name], + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-Command {name} -All -ErrorAction SilentlyContinue | " + f"Select-Object -ExpandProperty Source)", + ], + ] + else: + commands = [ + ["sh", "-lc", f"command -v {name}"], + ["bash", "-lc", f"type -a {name} 2>/dev/null"], + ["zsh", "-lc", f"whence -p {name} 2>/dev/null; command -v {name} 2>/dev/null"], + ["fish", "-lc", f"type -a {name} 2>/dev/null"], + ] + + for cmd in commands: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=3) + except Exception: + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + for line in result.stdout.strip().splitlines(): + resolved = _parse_shell_lookup_line(line) + if resolved: + return resolved + return None + + +def _glob_cli_paths(name: str) -> list[str]: + import glob + home = os.path.expanduser("~") + patterns = [ + os.path.join(home, ".claude", "bin", name), + os.path.join(home, ".claude", "*", "bin", name), + os.path.join(home, ".local", "share", "claude", "bin", name), + os.path.join(home, ".local", "share", "npm", "*", "bin", name), + ] + if sys.platform == "win32": + patterns.extend([ + os.path.join(home, ".claude", "bin", f"{name}.exe"), + os.path.join(home, ".claude", "bin", f"{name}.cmd"), + ]) + found: list[str] = [] + for pattern in patterns: + try: + found.extend(glob.glob(pattern)) + except Exception: + pass + return found + + +def _configured_cli_path(engine: str) -> Optional[str]: + env_key = "PODCLI_CLAUDE_PATH" if engine == "claude" else "PODCLI_CODEX_PATH" + raw = (os.environ.get(env_key) or "").strip() + if not raw: + try: + from services.env_settings import _read_pairs + raw = (_read_pairs().get(env_key) or "").strip() + except Exception: + pass + if not raw: + return None + return _resolve_cli_path(raw) or (raw if os.path.isfile(raw) else None) + + +def _find_cli(name: str, extra_paths: list[str] = None) -> Optional[str]: + import shutil + + for path in (extra_paths or []) + _glob_cli_paths(name): + resolved = _resolve_cli_path(path) + if resolved: + return resolved + + lookup_dirs = _all_lookup_dirs() + lookup_path = os.pathsep.join(lookup_dirs + [os.environ.get("PATH", "")]) + found = shutil.which(name, path=lookup_path) + if found: + return found + + for directory in lookup_dirs: + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + for directory in (os.environ.get("PATH", "") or "").split(os.pathsep): + if not directory: + continue + resolved = _resolve_cli_path(os.path.join(directory, name)) + if resolved: + return resolved + + return _shell_lookup(name) + + +def _ai_cli_search_paths(name: str) -> list[str]: + paths_out = [os.path.join(directory, name) for directory in _all_lookup_dirs()] + paths_out.extend(_glob_cli_paths(name)) return paths_out +def _env_cli_path(engine: str) -> Optional[str]: + return _configured_cli_path(engine) + + +def get_ai_cli_status() -> dict: + configured = { + "claude": _configured_cli_path("claude"), + "codex": _configured_cli_path("codex"), + } + candidates = [ + {"engine": engine, "path": path} + for path, engine in _find_ai_cli_candidates() + ] + return { + "configured": configured, + "candidates": candidates, + "available": bool(candidates), + "searched_dirs": _all_lookup_dirs(), + } + + def _find_ai_cli_candidates() -> list[tuple[str, str]]: - """Find all available AI CLIs in preference order.""" candidates = [] - claude = _find_cli("claude", _ai_cli_search_paths("claude")) + claude = _env_cli_path("claude") or _find_cli("claude", _ai_cli_search_paths("claude")) if claude: candidates.append((claude, "claude")) - codex = _find_cli("codex", _ai_cli_search_paths("codex")) + codex = _env_cli_path("codex") or _find_cli("codex", _ai_cli_search_paths("codex")) if codex: candidates.append((codex, "codex")) @@ -134,16 +431,20 @@ def _run_ai_command( pass return result - return subprocess.run( - f'cat "{prompt_file}" | "{cli_path}" --print -p -', - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - cwd=project_dir, - timeout=timeout, - shell=True, - ) + shell = sys.platform == "win32" and cli_path.lower().endswith((".cmd", ".bat")) + cmd = f'"{cli_path}" --print -p -' if shell else [cli_path, "--print", "-p", "-"] + with open(prompt_file, encoding="utf-8") as prompt_fh: + return subprocess.run( + cmd, + stdin=prompt_fh, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + cwd=project_dir, + timeout=timeout, + shell=shell, + ) def _load_existing_shorts(episodes_path: str) -> list[str]: diff --git a/backend/services/env_settings.py b/backend/services/env_settings.py index 5396b4f..d35be73 100644 --- a/backend/services/env_settings.py +++ b/backend/services/env_settings.py @@ -18,6 +18,22 @@ "url": "https://huggingface.co/settings/tokens", "secret": True, }, + { + "key": "PODCLI_CLAUDE_PATH", + "label": "Claude Code CLI path", + "help": "Full path to the claude binary when auto-discovery fails. " + "Usually ~/.local/bin/claude on macOS/Linux or " + "%USERPROFILE%\\.local\\bin\\claude.exe on Windows.", + "secret": False, + "placeholder": "/home/you/.local/bin/claude", + }, + { + "key": "PODCLI_CODEX_PATH", + "label": "Codex CLI path", + "help": "Full path to the codex binary when auto-discovery fails.", + "secret": False, + "placeholder": "/home/you/.local/bin/codex", + }, ] _KEYS = {s["key"] for s in SETTINGS} @@ -59,7 +75,8 @@ def list_settings() -> list[dict[str, Any]]: "key": s["key"], "label": s["label"], "help": s["help"], - "url": s["url"], + "url": s.get("url"), + "placeholder": s.get("placeholder"), "secret": s["secret"], "set": bool(raw), "preview": _mask(raw) if s["secret"] else raw, @@ -117,6 +134,12 @@ def set_setting(key: str, value: str) -> None: value = (value or "").strip() if not value: raise ValueError("value is empty") + if key in ("PODCLI_CLAUDE_PATH", "PODCLI_CODEX_PATH"): + from services.claude_suggest import _resolve_cli_path + resolved = _resolve_cli_path(value) or (value if os.path.isfile(value) else None) + if not resolved: + raise ValueError(f"path does not exist: {value}") + value = resolved _write_pairs({key: value}) @@ -129,7 +152,12 @@ def unset_setting(key: str) -> None: def run_env_action(action: str, key: Optional[str] = None, value: Optional[str] = None) -> dict[str, Any]: act = (action or "list").strip().lower() if act == "list": - return {"settings": list_settings(), "path": os.path.abspath(_env_path())} + from services.claude_suggest import get_ai_cli_status + return { + "settings": list_settings(), + "path": os.path.abspath(_env_path()), + "ai_cli": get_ai_cli_status(), + } if act == "set": if not key: raise ValueError("key is required") diff --git a/src/handlers/integrations.handler.ts b/src/handlers/integrations.handler.ts index 6de7232..ecb6474 100644 --- a/src/handlers/integrations.handler.ts +++ b/src/handlers/integrations.handler.ts @@ -112,6 +112,72 @@ export async function handleManageConfig(input: { return JSON.stringify(result.data, null, 2); } +interface EnvSettingRow { + key: string; + label: string; + help?: string; + url?: string; + placeholder?: string; + secret: boolean; + set: boolean; + preview?: string; +} + +interface AiCliStatus { + available?: boolean; + configured?: Record; + candidates?: Array<{ engine: string; path: string }>; +} + +export const manageEnvToolDef = { + name: "manage_env", + description: + "List, set, or unset global podcli settings stored in .env.\n\n" + + "Keys:\n" + + " • HF_TOKEN — HuggingFace token for speaker detection\n" + + " • PODCLI_CLAUDE_PATH — manual path to Claude Code CLI when auto-discovery fails\n" + + " • PODCLI_CODEX_PATH — manual path to Codex CLI when auto-discovery fails\n\n" + + "Actions:\n" + + " • list — show all settings, configured values, and AI CLI detection (default)\n" + + " • set — set a key (path must exist for PODCLI_*_PATH keys)\n" + + " • unset — remove a key (falls back to auto-discovery)", +}; + +export async function handleManageEnv(input: { + action?: "list" | "set" | "unset"; + key?: string; + value?: string; +}): Promise { + const action = input.action ?? "list"; + const result = await executor.execute<{ + settings?: EnvSettingRow[]; + path?: string; + ai_cli?: AiCliStatus; + ok?: boolean; + key?: string; + }>("manage_env", { + action, + key: input.key ?? "", + value: input.value ?? "", + }); + if (!result.data) throw new Error(`manage_env returned no data (action=${action})`); + return JSON.stringify(result.data, null, 2); +} + +export const aiCliStatusToolDef = { + name: "ai_cli_status", + description: + "Show whether Claude Code / Codex CLIs are available for AI-powered clip suggestion and content generation.\n\n" + + "Returns configured manual paths (PODCLI_CLAUDE_PATH / PODCLI_CODEX_PATH) and auto-discovered binaries. " + + "Use manage_env(action=set, key=PODCLI_CLAUDE_PATH, value=...) to override when detection fails.", +}; + +export async function handleAiCliStatus(): Promise { + const result = await executor.execute("ai_cli_status", {}); + if (!result.data) throw new Error("ai_cli_status returned no data"); + return JSON.stringify(result.data, null, 2); +} + function mcpText(text: string, isError = false) { return { content: [{ type: "text" as const, text }], @@ -192,4 +258,45 @@ export function registerIntegrationMcpTools(server: McpServer): void { } } ); + + server.tool( + manageEnvToolDef.name, + manageEnvToolDef.description, + { + action: z.enum(["list", "set", "unset"]).optional().default("list"), + key: z.string().optional().describe("HF_TOKEN | PODCLI_CLAUDE_PATH | PODCLI_CODEX_PATH"), + value: z.string().optional().describe("Value to set (required for set)"), + }, + async ({ action, key, value }) => { + try { + if (action === "set" && !key?.trim()) { + return mcpText("`key` is required when action is set.", true); + } + if (action === "set" && !value?.trim()) { + return mcpText("`value` is required when action is set.", true); + } + if (action === "unset" && !key?.trim()) { + return mcpText("`key` is required when action is unset.", true); + } + const text = await handleManageEnv({ action, key, value }); + return mcpText(text); + } catch (err) { + return mcpText(err instanceof Error ? err.message : String(err), true); + } + } + ); + + server.tool( + aiCliStatusToolDef.name, + aiCliStatusToolDef.description, + {}, + async () => { + try { + const text = await handleAiCliStatus(); + return mcpText(text); + } catch (err) { + return mcpText(err instanceof Error ? err.message : String(err), true); + } + } + ); } diff --git a/src/models/index.ts b/src/models/index.ts index 79831c1..5b24e87 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -2,7 +2,7 @@ export interface TaskRequest { task_id: string; - task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env"; + task_type: "transcribe" | "parse_transcript" | "create_clip" | "batch_clips" | "analyze_energy" | "pack_transcript" | "detect_encoder" | "presets" | "ping" | "suggest_clips" | "find_moment" | "generate_content" | "corrections" | "manage_integrations" | "run_integration_tool" | "manage_config" | "manage_env" | "ai_cli_status"; params: Record; } diff --git a/src/server.ts b/src/server.ts index 02a8bfe..469ef04 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2347,6 +2347,7 @@ export function createServer(): McpServer { "## Optional: DaVinci Resolve", "manage_integrations(action=enable, name=davinci_resolve) then export_to_davinci_resolve with source + caption overlay paths.", "Use manage_config(action=migrate) once after upgrading if transcription cache seems empty.", + "If clip suggestion fails with 'No AI CLI available', call ai_cli_status then manage_env(action=set, key=PODCLI_CLAUDE_PATH, value=/path/to/claude).", "", "## Available caption styles:", "- branded: Professional look with dark highlight box, gradient, optional logo", diff --git a/src/ui/client/ConfigPage.tsx b/src/ui/client/ConfigPage.tsx index dbd09bc..4e92ed9 100644 --- a/src/ui/client/ConfigPage.tsx +++ b/src/ui/client/ConfigPage.tsx @@ -1,6 +1,23 @@ import React, { useEffect, useRef, useState } from "react"; import { api, upload } from "./lib"; +type SettingRow = { + key: string; + label: string; + help?: string; + url?: string; + placeholder?: string; + secret: boolean; + set: boolean; + preview?: string; +}; + +type AiCliStatus = { + available?: boolean; + configured?: Record; + candidates?: Array<{ engine: string; path: string }>; +}; + export default function ConfigPage() { const [status, setStatus] = useState(null); const [statusError, setStatusError] = useState(null); @@ -8,8 +25,10 @@ export default function ConfigPage() { const [activate, setActivate] = useState(false); const [importing, setImporting] = useState(false); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); - const [settings, setSettings] = useState([]); + const [settings, setSettings] = useState([]); + const [aiCli, setAiCli] = useState(null); const [secretInputs, setSecretInputs] = useState>({}); + const [pathInputs, setPathInputs] = useState>({}); const [savingKey, setSavingKey] = useState(null); const fileRef = useRef(null); @@ -20,24 +39,63 @@ export default function ConfigPage() { async function loadSettings() { try { - const data = await api("/settings"); + const data = await api<{ settings?: SettingRow[]; ai_cli?: AiCliStatus }>("/settings"); setSettings(data.settings || []); + if (data.ai_cli) { + setAiCli(data.ai_cli); + } else { + try { + setAiCli(await api("/ai-cli-status")); + } catch { + setAiCli(null); + } + } } catch { /* settings are optional */ } } + async function refreshAiCli() { + try { + setAiCli(await api("/ai-cli-status")); + } catch { + setAiCli(null); + } + } + useEffect(() => { loadStatus().catch((e) => setStatusError(e.message)); loadSettings(); }, []); - async function saveSetting(key: string) { + async function saveSetting(key: string, secret: boolean) { setSavingKey(key); setMsg(null); + const value = secret ? (secretInputs[key] ?? "") : (pathInputs[key] ?? ""); try { - await api("/settings", { method: "POST", body: JSON.stringify({ key, value: secretInputs[key] ?? "" }) }); - setSecretInputs((p) => ({ ...p, [key]: "" })); + await api("/settings", { method: "POST", body: JSON.stringify({ key, value }) }); + if (secret) { + setSecretInputs((p) => ({ ...p, [key]: "" })); + } else { + setPathInputs((p) => ({ ...p, [key]: "" })); + } setMsg({ text: `${key} saved.`, ok: true }); await loadSettings(); + await refreshAiCli(); + } catch (e: any) { + setMsg({ text: e.message, ok: false }); + } finally { + setSavingKey(null); + } + } + + async function clearSetting(key: string) { + setSavingKey(key); + setMsg(null); + try { + await api("/settings", { method: "POST", body: JSON.stringify({ key, value: "" }) }); + setPathInputs((p) => ({ ...p, [key]: "" })); + setMsg({ text: `${key} cleared.`, ok: true }); + await loadSettings(); + await refreshAiCli(); } catch (e: any) { setMsg({ text: e.message, ok: false }); } finally { @@ -86,6 +144,9 @@ export default function ConfigPage() { ] : []; + const pathSettings = settings.filter((s) => !s.secret); + const secretSettings = settings.filter((s) => s.secret); + return (

Config

@@ -107,10 +168,74 @@ export default function ConfigPage() { )}
- {settings.length > 0 && ( +
+
AI CLI
+ {aiCli ? ( + <> +
+ Status + + {aiCli.available + ? `Found ${aiCli.candidates?.map((c) => c.engine).join(", ") || "CLI"}` + : "Not detected — set a path below or install Claude Code / Codex"} + +
+ {(aiCli.candidates || []).map((c) => ( +
+ {c.engine} + {c.path} +
+ ))} + + ) : ( +
Checking for Claude Code / Codex…
+ )} + {pathSettings.map((s) => ( +
+ + {s.help && ( +
{s.help}
+ )} +
+ setPathInputs((p) => ({ ...p, [s.key]: e.target.value }))} + style={{ fontSize: 13, flex: 1, fontFamily: "var(--font-mono)" }} + /> + + {s.set && ( + + )} +
+
+ ))} +
+ + {secretSettings.length > 0 && (
Secrets
- {settings.map((s) => ( + {secretSettings.map((s) => (
- Get token + {s.url && ( + Get token + )}
))} diff --git a/src/ui/web-server.ts b/src/ui/web-server.ts index 0c97c70..139b098 100644 --- a/src/ui/web-server.ts +++ b/src/ui/web-server.ts @@ -1632,6 +1632,19 @@ app.post("/api/settings", async (req, res) => { } }); +app.get("/api/ai-cli-status", async (_req, res) => { + try { + const result = await executor.execute<{ + configured?: Record; + candidates?: Array<{ engine: string; path: string }>; + available?: boolean; + }>("ai_cli_status", {}); + res.json(result.data ?? { available: false, candidates: [], configured: {} }); + } catch (err: unknown) { + res.status(500).json({ error: errMsg(err) }); + } +}); + app.get("/api/youtube/config", (_req, res) => { try { const all = JSON.parse(readFileSync(paths.integrations, "utf-8")); diff --git a/tests/test_ai_fallback.py b/tests/test_ai_fallback.py index 7dadb95..3a28ee1 100644 --- a/tests/test_ai_fallback.py +++ b/tests/test_ai_fallback.py @@ -2,6 +2,7 @@ import os import subprocess import sys +import tempfile import unittest from unittest import mock @@ -322,5 +323,138 @@ def test_thumbnail_layout_retries_with_codex(self): self.assertEqual(layout["box_y"], "78%") +class AICliDiscoveryTests(unittest.TestCase): + def test_find_cli_resolves_windows_cmd_shim(self): + with tempfile.TemporaryDirectory() as tmp: + shim = os.path.join(tmp, "claude.cmd") + with open(shim, "w", encoding="utf-8") as fh: + fh.write("@echo off\n") + with mock.patch.object(cs.sys, "platform", "win32"): + found = cs._find_cli("claude", [os.path.join(tmp, "claude")]) + self.assertEqual(found, shim) + + def test_find_cli_uses_home_bin(self): + with tempfile.TemporaryDirectory() as home: + bin_dir = os.path.join(home, "bin") + os.makedirs(bin_dir) + cli = os.path.join(bin_dir, "claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): + with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): + found = cs._find_cli("claude", []) + self.assertEqual(found, cli) + + def test_npmrc_prefix_is_searched(self): + with tempfile.TemporaryDirectory() as home: + prefix = os.path.join(home, "npm-prefix") + bin_dir = os.path.join(prefix, "bin") + os.makedirs(bin_dir) + cli = os.path.join(bin_dir, "claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with open(os.path.join(home, ".npmrc"), "w", encoding="utf-8") as fh: + fh.write(f"prefix={prefix}\n") + with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): + with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): + with mock.patch.object(cs, "_package_manager_bin_dirs", return_value=[]): + with mock.patch.object(cs, "_shell_lookup", return_value=None): + found = cs._find_cli("claude", []) + self.assertEqual(found, cli) + + def test_parse_shell_lookup_line_handles_type_a(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + path = tmp.name + try: + self.assertEqual(cs._parse_shell_lookup_line(f"claude is {path}"), path) + finally: + os.remove(path) + + def test_find_cli_uses_legacy_claude_local_path(self): + with tempfile.TemporaryDirectory() as home: + legacy_bin = os.path.join(home, ".claude", "local", "bin") + os.makedirs(legacy_bin) + cli = os.path.join(legacy_bin, "claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with mock.patch.dict(os.environ, {"HOME": home, "PATH": ""}, clear=False): + with mock.patch("os.path.expanduser", side_effect=lambda p: p.replace("~", home)): + found = cs._find_cli("claude", cs._ai_cli_search_paths("claude")) + self.assertEqual(found, cli) + + def test_env_override_prefers_podcli_claude_path(self): + with tempfile.TemporaryDirectory() as tmp: + cli = os.path.join(tmp, "my-claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with mock.patch.dict(os.environ, {"PODCLI_CLAUDE_PATH": cli, "PATH": ""}, clear=False): + with mock.patch.object(cs, "_find_cli", return_value=None) as find_mock: + candidates = cs._find_ai_cli_candidates() + find_mock.assert_called_once() + self.assertEqual(find_mock.call_args.args[0], "codex") + self.assertEqual(candidates[0], (cli, "claude")) + + def test_configured_path_reads_from_env_file(self): + with tempfile.TemporaryDirectory() as tmp: + env_file = os.path.join(tmp, ".env") + cli = os.path.join(tmp, "custom-claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with open(env_file, "w", encoding="utf-8") as fh: + fh.write(f"PODCLI_CLAUDE_PATH={cli}\n") + with mock.patch.dict(os.environ, {"PODCLI_ENV_FILE": env_file, "PATH": ""}, clear=False): + os.environ.pop("PODCLI_CLAUDE_PATH", None) + found = cs._configured_cli_path("claude") + self.assertEqual(found, cli) + + def test_find_cli_falls_back_to_shell_lookup(self): + with tempfile.TemporaryDirectory() as tmp: + cli = os.path.join(tmp, "claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + with mock.patch.object(cs, "_shell_lookup", return_value=cli): + with mock.patch("shutil.which", return_value=None): + found = cs._find_cli("claude", []) + self.assertEqual(found, cli) + + def test_get_ai_cli_status_reports_candidates(self): + with mock.patch.object( + cs, + "_find_ai_cli_candidates", + return_value=[("/tmp/claude", "claude")], + ), mock.patch.object(cs, "_configured_cli_path", return_value=None): + status = cs.get_ai_cli_status() + self.assertTrue(status["available"]) + self.assertEqual(status["candidates"][0]["engine"], "claude") + with tempfile.TemporaryDirectory() as tmp: + prompt_file = os.path.join(tmp, "prompt.txt") + with open(prompt_file, "w", encoding="utf-8") as fh: + fh.write("find clips") + cli = os.path.join(tmp, "claude") + with open(cli, "w", encoding="utf-8") as fh: + fh.write("#!/bin/sh\n") + + with mock.patch("services.claude_suggest.subprocess.run") as run_mock: + run_mock.return_value = subprocess.CompletedProcess( + args=[cli, "--print", "-p", "-"], + returncode=0, + stdout="{}", + stderr="", + ) + cs._run_ai_command( + cli_path=cli, + engine="claude", + prompt="find clips", + prompt_file=prompt_file, + project_dir=tmp, + timeout=30, + ) + + args, kwargs = run_mock.call_args + self.assertEqual(args[0], [cli, "--print", "-p", "-"]) + self.assertIn("stdin", kwargs) + self.assertFalse(kwargs.get("shell")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_env_settings.py b/tests/test_env_settings.py index 2bb1783..dc72195 100644 --- a/tests/test_env_settings.py +++ b/tests/test_env_settings.py @@ -28,6 +28,34 @@ def tearDown(self): os.environ["PODCLI_ENV_FILE"] = self._saved shutil.rmtree(self.tmp, ignore_errors=True) + def test_list_includes_ai_cli_paths(self): + keys = [s["key"] for s in env_settings.run_env_action("list")["settings"]] + self.assertIn("HF_TOKEN", keys) + self.assertIn("PODCLI_CLAUDE_PATH", keys) + self.assertIn("PODCLI_CODEX_PATH", keys) + + def test_set_claude_path_persists(self): + import tempfile + with tempfile.NamedTemporaryFile(delete=False) as tmp: + cli = tmp.name + try: + env_settings.set_setting("PODCLI_CLAUDE_PATH", cli) + s = next(x for x in env_settings.list_settings() if x["key"] == "PODCLI_CLAUDE_PATH") + self.assertTrue(s["set"]) + self.assertEqual(s["preview"], cli) + self.assertIn(f"PODCLI_CLAUDE_PATH={cli}", open(self.env).read()) + finally: + os.remove(cli) + + def test_set_claude_path_rejects_missing_file(self): + with self.assertRaises(ValueError): + env_settings.set_setting("PODCLI_CLAUDE_PATH", "/no/such/claude") + + def test_list_includes_ai_cli_status(self): + data = env_settings.run_env_action("list") + self.assertIn("ai_cli", data) + self.assertIn("available", data["ai_cli"]) + def test_list_unset(self): s = env_settings.run_env_action("list")["settings"] self.assertEqual(s[0]["key"], "HF_TOKEN")