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
19 changes: 18 additions & 1 deletion codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,10 @@ def tasks_generate(

console.print(f"Generating tasks from PRD: [bold]{prd_record.title}[/bold]")

if not no_llm:
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()

if no_llm:
console.print("[dim]Using simple extraction (--no-llm)[/dim]")
else:
Expand Down Expand Up @@ -2032,6 +2036,11 @@ def work_start(

task = matching[0]

# Validate API key before creating run record (avoids dangling IN_PROGRESS state)
if execute:
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()

# Start the run
run = runtime.start_task_run(workspace, task.id)

Expand Down Expand Up @@ -2351,7 +2360,7 @@ def work_diagnose(
report = existing_report
console.print("[dim]Using cached diagnostic report (use --force to re-analyze)[/dim]\n")
else:
# Run diagnostic analysis
# Run diagnostic analysis (LLM is optional — used if provider passed)
console.print("[bold]Analyzing run logs...[/bold]\n")
agent = DiagnosticAgent(workspace)
report = agent.analyze(task.id, latest_run.id)
Expand Down Expand Up @@ -2482,6 +2491,10 @@ def work_retry(

task = matching[0]

# Validate API key before any state modifications
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()

# Reset task to READY if it's FAILED or BLOCKED
if task.status in (TaskStatus.FAILED, TaskStatus.BLOCKED):
# Reset any blocked runs first
Expand Down Expand Up @@ -2929,6 +2942,10 @@ def batch_run(
console.print(f" [{i + 1}] {tid[:8]} - {title}")
return

# Validate API key before batch execution
from codeframe.cli.validators import require_anthropic_api_key
require_anthropic_api_key()

# Execute batch
if max_retries > 0:
console.print(f"\n[bold cyan]Starting batch execution (with up to {max_retries} retries)...[/bold cyan]\n")
Expand Down
48 changes: 48 additions & 0 deletions codeframe/cli/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""CLI validation helpers for pre-command checks."""

import os
from pathlib import Path

import typer
from dotenv import load_dotenv
from rich.console import Console

console = Console()


def require_anthropic_api_key() -> str:
"""Ensure ANTHROPIC_API_KEY is available, loading from .env if needed.

Checks os.environ first. If not found, attempts to load from .env files
(~/.env as base, then cwd/.env with override). If found after loading,
sets in os.environ so subprocesses inherit it.

Returns:
The API key string.

Raises:
typer.Exit: If the key cannot be found anywhere.
"""
key = os.getenv("ANTHROPIC_API_KEY")
if key:
return key

# Try loading from .env files (same priority as app.py)
cwd_env = Path.cwd() / ".env"
home_env = Path.home() / ".env"

if home_env.exists():
load_dotenv(home_env)
if cwd_env.exists():
load_dotenv(cwd_env, override=True)

key = os.getenv("ANTHROPIC_API_KEY")
if key:
os.environ["ANTHROPIC_API_KEY"] = key
return key

console.print(
"[red]Error:[/red] ANTHROPIC_API_KEY is not set. "
"Set it in your environment or add it to a .env file."
)
raise typer.Exit(1)
6 changes: 6 additions & 0 deletions codeframe/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,12 @@ def generate_from_prd(
if use_llm:
try:
tasks_data = _generate_tasks_with_llm(prd.content)
except json.JSONDecodeError as e:
# Invalid JSON from LLM response — fall back to simple extraction
print(f"LLM generation failed ({e}), using simple extraction")
tasks_data = _extract_tasks_simple(prd.content)
except ValueError:
raise # Config errors (missing API key) should fail loudly
except Exception as e:
# Fall back to simple extraction
print(f"LLM generation failed ({e}), using simple extraction")
Expand Down
194 changes: 194 additions & 0 deletions tests/core/test_cli_validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
"""Tests for codeframe.cli.validators module."""

import os
from pathlib import Path

import click
import pytest
from typer.testing import CliRunner

runner = CliRunner()


class TestRequireAnthropicApiKey:
"""Tests for require_anthropic_api_key() validator."""

def test_returns_key_when_in_environment(self, monkeypatch):
"""When ANTHROPIC_API_KEY is already set, return it directly."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test-key-123")

from codeframe.cli.validators import require_anthropic_api_key

result = require_anthropic_api_key()
assert result == "sk-ant-test-key-123"

def test_loads_key_from_dotenv_file(self, monkeypatch, tmp_path):
"""When key is not in env but exists in .env file, load and return it."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

# Create a .env file with the key
env_file = tmp_path / ".env"
env_file.write_text("ANTHROPIC_API_KEY=sk-ant-from-dotenv-456\n")

# Change to the tmp_path directory so load_dotenv finds .env
monkeypatch.chdir(tmp_path)

from codeframe.cli.validators import require_anthropic_api_key

result = require_anthropic_api_key()
assert result == "sk-ant-from-dotenv-456"

def test_sets_key_in_environ_after_loading_from_dotenv(self, monkeypatch, tmp_path):
"""After loading from .env, the key should be available in os.environ."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

env_file = tmp_path / ".env"
env_file.write_text("ANTHROPIC_API_KEY=sk-ant-persist-789\n")
monkeypatch.chdir(tmp_path)

from codeframe.cli.validators import require_anthropic_api_key

require_anthropic_api_key()
assert os.environ.get("ANTHROPIC_API_KEY") == "sk-ant-persist-789"

def test_raises_exit_when_key_missing_everywhere(self, monkeypatch, tmp_path):
"""When key is not in env and not in any .env file, raise SystemExit."""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)

# Use a directory with no .env file and isolate Path.home() to prevent
# loading from the real ~/.env on developer machines
monkeypatch.chdir(tmp_path)
fake_home = tmp_path / "fakehome"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))

from codeframe.cli.validators import require_anthropic_api_key

with pytest.raises(click.exceptions.Exit):
require_anthropic_api_key()


# ---------------------------------------------------------------------------
# Fixtures for CLI integration tests
# ---------------------------------------------------------------------------

SAMPLE_PRD = """\
# Sample PRD

## Feature: User Authentication
- Implement login endpoint
- Implement signup endpoint
"""


@pytest.fixture
def workspace_with_prd(tmp_path):
"""Initialized workspace with a PRD added."""
from codeframe.cli.app import app
from codeframe.core.workspace import create_or_load_workspace

repo = tmp_path / "repo"
repo.mkdir()
create_or_load_workspace(repo)

prd_file = repo / "prd.md"
prd_file.write_text(SAMPLE_PRD)

result = runner.invoke(app, ["prd", "add", str(prd_file), "-w", str(repo)])
assert result.exit_code == 0, f"prd add failed: {result.output}"
return repo


@pytest.fixture
def workspace_with_ready_task(workspace_with_prd):
"""Workspace with a PRD, generated tasks, and one READY task."""
from codeframe.cli.app import app

wp = str(workspace_with_prd)

result = runner.invoke(app, ["tasks", "generate", "--no-llm", "-w", wp])
assert result.exit_code == 0, f"tasks generate failed: {result.output}"

result = runner.invoke(app, ["tasks", "set", "status", "READY", "--all", "-w", wp])
assert result.exit_code == 0, f"set ready failed: {result.output}"

# Get first task ID
from codeframe.core import tasks
from codeframe.core.workspace import get_workspace

workspace = get_workspace(workspace_with_prd)
all_tasks = tasks.list_tasks(workspace)
assert len(all_tasks) > 0, "No tasks generated"

return workspace_with_prd, all_tasks[0].id


# ---------------------------------------------------------------------------
# CLI Integration: tasks generate validation
# ---------------------------------------------------------------------------


class TestTasksGenerateValidation:
"""Test that tasks generate validates API key when using LLM."""

def test_tasks_generate_without_key_exits(self, workspace_with_prd, monkeypatch, tmp_path):
"""tasks generate (LLM mode) should fail early when API key is missing."""
from codeframe.cli.app import app

monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.chdir(tmp_path) # No .env here

result = runner.invoke(
app, ["tasks", "generate", "-w", str(workspace_with_prd)]
)
assert result.exit_code != 0
assert "ANTHROPIC_API_KEY" in result.output

def test_tasks_generate_no_llm_skips_validation(self, workspace_with_prd, monkeypatch, tmp_path):
"""tasks generate --no-llm should succeed without API key."""
from codeframe.cli.app import app

monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)

result = runner.invoke(
app, ["tasks", "generate", "--no-llm", "-w", str(workspace_with_prd)]
)
assert result.exit_code == 0
assert "generated" in result.output.lower()


# ---------------------------------------------------------------------------
# CLI Integration: work start validation
# ---------------------------------------------------------------------------


class TestWorkStartValidation:
"""Test that work start --execute validates API key."""

def test_work_start_execute_without_key_exits(self, workspace_with_ready_task, monkeypatch, tmp_path):
"""work start --execute should fail early when API key is missing."""
from codeframe.cli.app import app

workspace_path, task_id = workspace_with_ready_task
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)

result = runner.invoke(
app, ["work", "start", task_id, "--execute", "-w", str(workspace_path)]
)
assert result.exit_code != 0
assert "ANTHROPIC_API_KEY" in result.output

def test_work_start_stub_skips_validation(self, workspace_with_ready_task, monkeypatch, tmp_path):
"""work start --stub should succeed without API key."""
from codeframe.cli.app import app

workspace_path, task_id = workspace_with_ready_task
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.chdir(tmp_path)

result = runner.invoke(
app, ["work", "start", task_id, "--stub", "-w", str(workspace_path)]
)
assert result.exit_code == 0
24 changes: 22 additions & 2 deletions tests/e2e/cli/golden_path_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,31 @@ def _run_cmd(
)

def _build_env(self) -> dict:
"""Build environment with API key."""
"""Build environment with API key from .env if needed."""
import os

env = os.environ.copy()
# Ensure ANTHROPIC_API_KEY propagates
if "ANTHROPIC_API_KEY" not in env:
codeframe_root = Path(
os.getenv(
"CODEFRAME_ROOT",
str(Path(__file__).parents[3]),
)
)
search_paths = [
Path.cwd() / ".env",
codeframe_root / ".env",
]
for env_path in search_paths:
if env_path.exists():
for line in env_path.read_text().splitlines():
line = line.strip()
if line.startswith("ANTHROPIC_API_KEY="):
key = line.split("=", 1)[1].strip().strip('"').strip("'")
env["ANTHROPIC_API_KEY"] = key
break
if "ANTHROPIC_API_KEY" in env:
break
return env

def _log(self, msg: str) -> None:
Expand Down
Loading
Loading