diff --git a/.gitignore b/.gitignore index 7094ca7..db994d0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ credentials/ .claude/ *.pid memory.bak/ +.use_openrouter diff --git a/cli.py b/cli.py index dcffa79..c5419c2 100755 --- a/cli.py +++ b/cli.py @@ -580,6 +580,51 @@ def cmd_whatsapp(args): _whatsapp_qr() +def cmd_openrouter(args): + """Handle openrouter subcommand.""" + semaphore = PROJECT_ROOT / ".use_openrouter" + + if args.openrouter_action == "on": + env_file = Path.home() / ".claude-env.sh" + if not env_file.exists(): + print(f"Error: {env_file} not found") + sys.exit(1) + # Verify required vars exist in file + text = env_file.read_text() + if "ANTHROPIC_BASE_URL" not in text or "OPENROUTER_API_KEY" not in text: + print(f"Warning: {env_file} may be missing ANTHROPIC_BASE_URL or OPENROUTER_API_KEY") + + semaphore.write_text("# OpenRouter mode enabled\n") + print("OpenRouter mode: ENABLED") + print("Restart echo-core for changes to take effect:") + print(" systemctl --user restart echo-core") + + elif args.openrouter_action == "off": + if semaphore.exists(): + semaphore.unlink() + print("OpenRouter mode: DISABLED") + print("Restart echo-core to use Anthropic API:") + print(" systemctl --user restart echo-core") + else: + print("OpenRouter mode: already disabled") + + elif args.openrouter_action == "status": + status = "ENABLED" if semaphore.exists() else "DISABLED" + print(f"OpenRouter mode: {status}") + if semaphore.exists(): + print(f"Semafor: {semaphore}") + env_file = Path.home() / ".claude-env.sh" + if env_file.exists(): + # Show which vars will be loaded + print(f"Env file: {env_file}") + text = env_file.read_text() + for line in text.splitlines(): + if line.strip().startswith("export ") and not line.strip().startswith("#"): + var_name = line.strip()[7:].split("=")[0] if "=" in line else "?" + if any(x in var_name for x in ["ANTHROPIC", "OPENROUTER"]): + print(f" {var_name}=***") + + def _whatsapp_status(): """Check WhatsApp bridge connection status.""" import urllib.request @@ -806,6 +851,14 @@ def main(): whatsapp_sub.add_parser("status", help="Check bridge connection status") whatsapp_sub.add_parser("qr", help="Show QR code instructions") + # openrouter + openrouter_parser = sub.add_parser("openrouter", help="Toggle OpenRouter mode") + openrouter_sub = openrouter_parser.add_subparsers(dest="openrouter_action") + + openrouter_sub.add_parser("on", help="Enable OpenRouter mode") + openrouter_sub.add_parser("off", help="Disable OpenRouter mode") + openrouter_sub.add_parser("status", help="Check OpenRouter status") + # Parse and dispatch args = parser.parse_args() @@ -839,6 +892,9 @@ def main(): "whatsapp": lambda a: ( cmd_whatsapp(a) if a.whatsapp_action else (whatsapp_parser.print_help() or sys.exit(0)) ), + "openrouter": lambda a: ( + cmd_openrouter(a) if a.openrouter_action else (openrouter_parser.print_help() or sys.exit(0)) + ), } handler = dispatch.get(args.command) diff --git a/src/claude_session.py b/src/claude_session.py index a47da6b..3f66db3 100644 --- a/src/claude_session.py +++ b/src/claude_session.py @@ -107,12 +107,89 @@ if not shutil.which(CLAUDE_BIN): # --------------------------------------------------------------------------- +def _expand_env_vars(value: str, env: dict[str, str]) -> str: + """Expand $VAR and ${VAR} patterns using values from env dict.""" + import re + + def replacer(match: re.Match) -> str: + var_name = match.group(1) or match.group(2) + return env.get(var_name, match.group(0)) + + # Match ${VAR} or $VAR + return re.sub(r'\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)', replacer, value) + + +def _load_env_file(path: Path, target_env: dict[str, str]) -> None: + """Parse shell export statements from env file and update target_env.""" + try: + text = path.read_text(encoding="utf-8") + for line in text.splitlines(): + line = line.strip() + if line.startswith("export "): + # Parse: export VAR="value" or export VAR=value + rest = line[7:] # remove "export " + if "=" in rest: + key, val = rest.split("=", 1) + key = key.strip() + # Remove inline comments (everything after unquoted #) + val = _strip_shell_comments(val.strip()) + # Remove quotes if present + val = val.strip('"').strip("'") + if key and not key.startswith("#"): + # Expand shell variables like $OPENROUTER_API_KEY + val = _expand_env_vars(val, target_env) + target_env[key] = val + except OSError: + pass + + +def _strip_shell_comments(value: str) -> str: + """Remove shell-style comments from a value string. + + Handles quoted # characters correctly. + """ + in_single = False + in_double = False + escaped = False + + for i, char in enumerate(value): + if escaped: + escaped = False + continue + if char == '\\': + escaped = True + continue + if char == '"' and not in_single: + in_double = not in_double + elif char == "'" and not in_double: + in_single = not in_single + elif char == '#' and not in_single and not in_double: + # Comment starts here + return value[:i].rstrip() + + return value + + def _safe_env() -> dict[str, str]: - """Return os.environ minus sensitive/problematic variables.""" - stripped = {k for k in _ENV_STRIP if k in os.environ} + """Return os.environ minus sensitive/problematic variables. + + Daca exista fisierul .use_openrouter, incarca variabilele din ~/.claude-env.sh + """ + env = dict(os.environ) + + # Check for OpenRouter toggle + semaphore = PROJECT_ROOT / ".use_openrouter" + env_file = Path.home() / ".claude-env.sh" + if semaphore.exists() and env_file.exists(): + # Parse and load environment variables from .claude-env.sh + _load_env_file(env_file, env) + # Keep ANTHROPIC_DEFAULT_*_MODEL vars - Claude CLI uses them to translate + # haiku/sonnet/opus aliases to OpenRouter model names + + stripped = {k for k in _ENV_STRIP if k in env} if stripped: _security_log.debug("Stripped env vars from subprocess: %s", stripped) - return {k: v for k, v in os.environ.items() if k not in _ENV_STRIP} + return {k: v for k, v in env.items() if k not in _ENV_STRIP} def _load_sessions() -> dict: @@ -242,8 +319,13 @@ def _run_claude( if proc.returncode != 0: stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else "" - detail = stderr_output[:500] or stdout_tail[:500] + # Check if result_obj has an error + result_error = "" + if result_obj and result_obj.get("is_error"): + result_error = result_obj.get("result", "") or result_obj.get("error", "") + detail = stderr_output[:500] or result_error[:500] or stdout_tail[:500] logger.error("Claude CLI stderr: %s", stderr_output[:1000]) + logger.error("Claude CLI result_obj: %s", result_obj) raise RuntimeError( f"Claude CLI error (exit {proc.returncode}): {detail}" ) @@ -385,14 +467,14 @@ def resume_session( If *on_text* is provided, each intermediate Claude text block is passed to the callback as soon as it arrives. """ - # Find channel/model for logging + # Find channel/model for logging and model selection sessions = _load_sessions() _log_channel = "?" - _log_model = "?" + _log_model = DEFAULT_MODEL for cid, sess in sessions.items(): if sess.get("session_id") == session_id: _log_channel = cid - _log_model = sess.get("model", "?") + _log_model = sess.get("model", DEFAULT_MODEL) break # Wrap external user message with injection protection markers @@ -401,6 +483,7 @@ def resume_session( cmd = [ CLAUDE_BIN, "-p", wrapped_message, "--resume", session_id, + "--model", _log_model, "--output-format", "stream-json", "--verbose", "--allowedTools", *ALLOWED_TOOLS, ] @@ -452,10 +535,15 @@ def send_message( ) -> str: """High-level convenience: auto start or resume based on channel state.""" session = get_active_session(channel_id) - if session is not None: + # Only resume if session has a valid session_id (not a pre-set model placeholder) + if session is not None and session.get("session_id"): return resume_session(session["session_id"], message, timeout, on_text=on_text) + # Use model from pre-set session if available, otherwise use provided model + effective_model = model + if session is not None and session.get("model"): + effective_model = session["model"] response_text, _session_id = start_session( - channel_id, message, model, timeout, on_text=on_text + channel_id, message, effective_model, timeout, on_text=on_text ) return response_text diff --git a/tools/youtube_subs.py b/tools/youtube_subs.py index cda3d36..cc04672 100755 --- a/tools/youtube_subs.py +++ b/tools/youtube_subs.py @@ -49,18 +49,21 @@ def get_subtitles(url, lang='en'): f.unlink() # First, get video info - info_cmd = [yt_dlp, '--dump-json', '--no-download', url] - try: - result = subprocess.run(info_cmd, capture_output=True, text=True, timeout=30) - if result.returncode == 0: + title = "Unknown" + info_cmd = [yt_dlp, '--js-runtimes', 'node', '--dump-json', '--no-download', url] + result = subprocess.run(info_cmd, capture_output=True, text=True, timeout=30) + print(f"INFO: returncode={result.returncode}, stderr={result.stderr[:200]}", file=sys.stderr) + if result.returncode == 0: + try: info = json.loads(result.stdout) title = info.get('title', 'Unknown') duration = info.get('duration', 0) print(f"Title: {title}", file=sys.stderr) print(f"Duration: {duration//60}:{duration%60:02d}", file=sys.stderr) - except Exception as e: - title = "Unknown" - print(f"Could not get video info: {e}", file=sys.stderr) + except Exception as e: + print(f"JSON parse error: {e}", file=sys.stderr) + else: + print(f"yt-dlp failed: {result.stderr[:500]}", file=sys.stderr) # Try to get subtitles in order of preference lang_preferences = [lang, 'ro', 'en', 'en-US', 'en-GB'] @@ -69,6 +72,7 @@ def get_subtitles(url, lang='en'): # Try manual subtitles first cmd = [ yt_dlp, + '--js-runtimes', 'node', '--write-subs', '--sub-langs', try_lang, '--skip-download', @@ -88,6 +92,7 @@ def get_subtitles(url, lang='en'): for try_lang in lang_preferences: cmd = [ yt_dlp, + '--js-runtimes', 'node', '--write-auto-subs', '--sub-langs', try_lang, '--skip-download', @@ -104,7 +109,7 @@ def get_subtitles(url, lang='en'): if text: return title, text - return title, None + return title or "Unknown", None if __name__ == '__main__': if len(sys.argv) < 2: