Skip to content
Open
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
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,24 @@
# OPENAI_API_KEY=
# GEMINI_API_KEY=
# GOOGLE_API_KEY=

# ---------------------------------------------------------------------------
# Local agent CLI backend (no API keys)
# ---------------------------------------------------------------------------
# Set R2S_LLM_BACKEND=cli to route LLM calls through a locally installed
# agent CLI instead of Azure OpenAI. Requires that CLI to be authenticated
# via its own login flow (e.g. `claude login`).
# R2S_LLM_BACKEND=cli
# R2S_CLI_TOOL=claude # claude | codex | kimi | omp
# R2S_CLI_MODEL= # optional model override passed to the CLI

# Set R2S_VIDEO_BACKEND=cli to analyze YouTube videos using local yt-dlp
# subtitles + ffmpeg keyframes instead of Gemini native video understanding.
# R2S_VIDEO_BACKEND=cli

# When native subtitles are missing, fall back to local ASR on the downloaded
# audio. Requires faster-whisper or openai-whisper installed. Set to "auto"
# to use the fastest available backend, or explicitly "faster-whisper" / "whisper".
# R2S_VIDEO_ASR=auto
# R2S_VIDEO_ASR_MODEL=tiny # tiny | base | small (faster-whisper also supports medium/large-v3)

33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,39 @@ AZURE_OPENAI_API_KEY=<your-key>
Per-model overrides (e.g. `AZURE_OPENAI_ENDPOINT_54`, `AZURE_OPENAI_DEPLOYMENT_54`)
are in `.env.example`.

## Running without API keys

If you have a local agent CLI installed and authenticated, you can run most of
Resource2Skill without Azure OpenAI or Gemini keys.

```bash
cp .env.example .env
# edit .env: set R2S_LLM_BACKEND=cli and R2S_CLI_TOOL=claude

# or export for one shell
export R2S_LLM_BACKEND=cli
export R2S_CLI_TOOL=claude

# video analysis also works without Gemini by using yt-dlp subtitles + ffmpeg keyframes
export R2S_VIDEO_BACKEND=cli

# when no native subtitles exist, fall back to local ASR (faster-whisper or openai-whisper)
export R2S_VIDEO_ASR=auto
```

You can also leave the variables unset: if `AZURE_OPENAI_API_KEY` is missing and
an agent CLI is on PATH, the system defaults to `R2S_LLM_BACKEND=cli`. If
`GEMINI_API_KEY` is missing, video analysis defaults to `R2S_VIDEO_BACKEND=cli`.
If `R2S_VIDEO_ASR=auto` is set and faster-whisper/openai-whisper is installed,
native-subtitle failure falls back to local ASR on the downloaded audio.

Tested: `claude` supports multi-turn session resume, a text-based tool-call
protocol, and reading local image files in headless mode. `codex`, `kimi`, and
`omp` backends are not yet implemented.

If you are an agent reading this repo, use `skill/Resource2Skill/SKILL.md` as
the connector for driving `cli.py`.

## Quick Start

```bash
Expand Down
3 changes: 3 additions & 0 deletions core/agent_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import logging
import os
import re
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
Expand Down Expand Up @@ -2086,6 +2087,7 @@ async def _run_loop(
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Task: {task}\n\n{initial_prompt}"},
]
conversation_id = uuid.uuid4().hex

# Inject reference frames as a vision message if available
all_frames = []
Expand Down Expand Up @@ -2177,6 +2179,7 @@ async def _run_loop(
messages,
tools=openai_tools,
model=self._model,
conversation_id=conversation_id,
reasoning_effort=self._reasoning,
max_completion_tokens=16384,
max_retries=10,
Expand Down
39 changes: 36 additions & 3 deletions core/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@
import time
import logging
from pathlib import Path
from google import genai
from google.genai import types
try:
from google import genai
from google.genai import types
except ImportError as _genai_import_err: # pragma: no cover - optional dependency
genai = None # type: ignore[assignment]
types = None # type: ignore[assignment]

from core.analyzer_cli_video import analyze_video_cli

log = logging.getLogger("analyzer")

Expand All @@ -41,7 +47,12 @@ def _load_prompt(path: Path | str | None = None) -> str:
return p.read_text(encoding="utf-8")


def _make_client(api_key: str | None = None) -> genai.Client:
def _make_client(api_key: str | None = None):
if genai is None:
raise RuntimeError(
"google-genai package is not installed. "
"Install it or use R2S_VIDEO_BACKEND=cli for keyless video analysis."
)
key = api_key or os.environ.get("GEMINI_API_KEY")
if not key:
raise ValueError(
Expand All @@ -63,6 +74,10 @@ def analyze_video(
) -> str:
"""Analyze a YouTube video and return a skill extraction in Markdown.

Defaults to Gemini native video understanding. When ``R2S_VIDEO_BACKEND=cli``
is set, falls back to a local, keyless pipeline: yt-dlp subtitles +
ffmpeg keyframes + local agent CLI (Claude Code) reasoning.

Args:
video_url: Public YouTube URL.
model: Gemini model to use.
Expand All @@ -76,6 +91,24 @@ def analyze_video(
Returns:
Raw Markdown analysis from the model.
"""
video_backend = os.environ.get("R2S_VIDEO_BACKEND", "").strip().lower()
gemini_key = api_key or os.environ.get("GEMINI_API_KEY")
if video_backend == "cli" or (not video_backend and not gemini_key):
if not video_backend and not gemini_key:
import shutil as _shutil
if _shutil.which("claude") or _shutil.which("yt-dlp"):
log.info("GEMINI_API_KEY not set; defaulting to CLI video backend")
else:
log.warning("GEMINI_API_KEY not set and no CLI/yt-dlp found; CLI video backend may fail")
return analyze_video_cli(
video_url,
prompt_path=prompt_path,
prompt_text=prompt_text,
extra_instructions=extra_instructions,
start_offset=start_offset,
end_offset=end_offset,
)

client = _make_client(api_key)

if prompt_text:
Expand Down
Loading