Use root env example for fcc init
Browse files- cli/entrypoints.py +18 -6
- config/env.example +0 -99
- pyproject.toml +3 -0
- tests/cli/test_entrypoints.py +14 -5
- tests/contracts/test_architecture_contracts.py +16 -14
cli/entrypoints.py
CHANGED
|
@@ -2,6 +2,23 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def serve() -> None:
|
| 7 |
"""Start the FastAPI server (registered as `free-claude-code` script)."""
|
|
@@ -25,9 +42,6 @@ def serve() -> None:
|
|
| 25 |
|
| 26 |
def init() -> None:
|
| 27 |
"""Scaffold config at ~/.config/free-claude-code/.env (registered as `fcc-init`)."""
|
| 28 |
-
import importlib.resources
|
| 29 |
-
from pathlib import Path
|
| 30 |
-
|
| 31 |
config_dir = Path.home() / ".config" / "free-claude-code"
|
| 32 |
env_file = config_dir / ".env"
|
| 33 |
|
|
@@ -37,9 +51,7 @@ def init() -> None:
|
|
| 37 |
return
|
| 38 |
|
| 39 |
config_dir.mkdir(parents=True, exist_ok=True)
|
| 40 |
-
template = (
|
| 41 |
-
importlib.resources.files("config").joinpath("env.example").read_text("utf-8")
|
| 42 |
-
)
|
| 43 |
env_file.write_text(template, encoding="utf-8")
|
| 44 |
print(f"Config created at {env_file}")
|
| 45 |
print(
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _load_env_template() -> str:
|
| 9 |
+
"""Load the canonical root env template from package resources or source."""
|
| 10 |
+
import importlib.resources
|
| 11 |
+
|
| 12 |
+
packaged = importlib.resources.files("cli").joinpath("env.example")
|
| 13 |
+
if packaged.is_file():
|
| 14 |
+
return packaged.read_text("utf-8")
|
| 15 |
+
|
| 16 |
+
source_template = Path(__file__).resolve().parents[1] / ".env.example"
|
| 17 |
+
if source_template.is_file():
|
| 18 |
+
return source_template.read_text(encoding="utf-8")
|
| 19 |
+
|
| 20 |
+
raise FileNotFoundError("Could not find bundled or source .env.example template.")
|
| 21 |
+
|
| 22 |
|
| 23 |
def serve() -> None:
|
| 24 |
"""Start the FastAPI server (registered as `free-claude-code` script)."""
|
|
|
|
| 42 |
|
| 43 |
def init() -> None:
|
| 44 |
"""Scaffold config at ~/.config/free-claude-code/.env (registered as `fcc-init`)."""
|
|
|
|
|
|
|
|
|
|
| 45 |
config_dir = Path.home() / ".config" / "free-claude-code"
|
| 46 |
env_file = config_dir / ".env"
|
| 47 |
|
|
|
|
| 51 |
return
|
| 52 |
|
| 53 |
config_dir.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
template = _load_env_template()
|
|
|
|
|
|
|
| 55 |
env_file.write_text(template, encoding="utf-8")
|
| 56 |
print(f"Config created at {env_file}")
|
| 57 |
print(
|
config/env.example
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 1 |
-
# NVIDIA NIM Config
|
| 2 |
-
NVIDIA_NIM_API_KEY=""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
# OpenRouter Config
|
| 6 |
-
OPENROUTER_API_KEY=""
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
# DeepSeek Config
|
| 10 |
-
DEEPSEEK_API_KEY=""
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
# LM Studio Config (local provider, no API key required)
|
| 14 |
-
LM_STUDIO_BASE_URL="http://localhost:1234/v1"
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
# Llama.cpp Config (local provider, no API key required)
|
| 18 |
-
LLAMACPP_BASE_URL="http://localhost:8080/v1"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# All Claude model requests are mapped to these models, plain model is fallback
|
| 22 |
-
# Format: provider_type/model/name
|
| 23 |
-
# Valid providers: "nvidia_nim" | "open_router" | "deepseek" | "lmstudio" | "llamacpp"
|
| 24 |
-
MODEL_OPUS=
|
| 25 |
-
MODEL_SONNET=
|
| 26 |
-
MODEL_HAIKU=
|
| 27 |
-
MODEL="nvidia_nim/z-ai/glm4.7"
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
# Thinking output
|
| 31 |
-
# Per-Claude-model switches for provider reasoning requests and Claude thinking blocks.
|
| 32 |
-
# Blank per-model switches inherit ENABLE_MODEL_THINKING.
|
| 33 |
-
ENABLE_OPUS_THINKING=
|
| 34 |
-
ENABLE_SONNET_THINKING=
|
| 35 |
-
ENABLE_HAIKU_THINKING=
|
| 36 |
-
ENABLE_MODEL_THINKING=true
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# Provider config
|
| 40 |
-
# Per-provider proxy support: http and socks5, example: "http://username:password@host:port"
|
| 41 |
-
NVIDIA_NIM_PROXY=""
|
| 42 |
-
OPENROUTER_PROXY=""
|
| 43 |
-
LMSTUDIO_PROXY=""
|
| 44 |
-
LLAMACPP_PROXY=""
|
| 45 |
-
|
| 46 |
-
PROVIDER_RATE_LIMIT=40
|
| 47 |
-
PROVIDER_RATE_WINDOW=60
|
| 48 |
-
PROVIDER_MAX_CONCURRENCY=5
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
# HTTP client timeouts (seconds) for provider API requests
|
| 52 |
-
HTTP_READ_TIMEOUT=120
|
| 53 |
-
HTTP_WRITE_TIMEOUT=10
|
| 54 |
-
HTTP_CONNECT_TIMEOUT=2
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
# Optional server API key (Anthropic-style)
|
| 58 |
-
ANTHROPIC_AUTH_TOKEN=
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
# Messaging Platform: "telegram" | "discord" | "none"
|
| 62 |
-
MESSAGING_PLATFORM="discord"
|
| 63 |
-
MESSAGING_RATE_LIMIT=1
|
| 64 |
-
MESSAGING_RATE_WINDOW=1
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# Voice Note Transcription
|
| 68 |
-
VOICE_NOTE_ENABLED=false
|
| 69 |
-
# WHISPER_DEVICE: "cpu" | "cuda" | "nvidia_nim"
|
| 70 |
-
# - "cpu"/"cuda": Hugging Face transformers Whisper (offline, free; install with: uv sync --extra voice_local)
|
| 71 |
-
# - "nvidia_nim": NVIDIA NIM Whisper via Riva gRPC (requires NVIDIA_NIM_API_KEY; install with: uv sync --extra voice)
|
| 72 |
-
WHISPER_DEVICE="nvidia_nim"
|
| 73 |
-
# WHISPER_MODEL:
|
| 74 |
-
# - For cpu/cuda: Hugging Face ID or short name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
|
| 75 |
-
# - For nvidia_nim: NVIDIA NIM model (e.g., "nvidia/parakeet-ctc-1.1b-asr", "openai/whisper-large-v3")
|
| 76 |
-
# - For nvidia_nim, default to "openai/whisper-large-v3" for best performance
|
| 77 |
-
WHISPER_MODEL="openai/whisper-large-v3"
|
| 78 |
-
HF_TOKEN=""
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
# Telegram Config
|
| 82 |
-
TELEGRAM_BOT_TOKEN=""
|
| 83 |
-
ALLOWED_TELEGRAM_USER_ID=""
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
# Discord Config
|
| 87 |
-
DISCORD_BOT_TOKEN=""
|
| 88 |
-
ALLOWED_DISCORD_CHANNELS=""
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
# Agent Config
|
| 92 |
-
CLAUDE_WORKSPACE="./agent_workspace"
|
| 93 |
-
ALLOWED_DIR=""
|
| 94 |
-
CLAUDE_CLI_BIN="claude"
|
| 95 |
-
FAST_PREFIX_DETECTION=true
|
| 96 |
-
ENABLE_NETWORK_PROBE_MOCK=true
|
| 97 |
-
ENABLE_TITLE_GENERATION_SKIP=true
|
| 98 |
-
ENABLE_SUGGESTION_MODE_SKIP=true
|
| 99 |
-
ENABLE_FILEPATH_EXTRACTION_MOCK=true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pyproject.toml
CHANGED
|
@@ -44,6 +44,9 @@ voice_local = [
|
|
| 44 |
[tool.hatch.build.targets.wheel]
|
| 45 |
packages = ["api", "cli", "config", "core", "messaging", "providers"]
|
| 46 |
|
|
|
|
|
|
|
|
|
|
| 47 |
[tool.uv.sources]
|
| 48 |
torch = { index = "pytorch-cu130" }
|
| 49 |
|
|
|
|
| 44 |
[tool.hatch.build.targets.wheel]
|
| 45 |
packages = ["api", "cli", "config", "core", "messaging", "providers"]
|
| 46 |
|
| 47 |
+
[tool.hatch.build.targets.wheel.force-include]
|
| 48 |
+
".env.example" = "cli/env.example"
|
| 49 |
+
|
| 50 |
[tool.uv.sources]
|
| 51 |
torch = { index = "pytorch-cu130" }
|
| 52 |
|
tests/cli/test_entrypoints.py
CHANGED
|
@@ -33,17 +33,26 @@ def test_init_creates_env_file(tmp_path: Path) -> None:
|
|
| 33 |
|
| 34 |
|
| 35 |
def test_init_copies_template_content(tmp_path: Path) -> None:
|
| 36 |
-
"""init() writes the
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
template = (
|
| 40 |
-
importlib.resources.files("config").joinpath("env.example").read_text("utf-8")
|
| 41 |
)
|
| 42 |
_, env_file = _run_init(tmp_path)
|
| 43 |
|
| 44 |
assert env_file.read_text("utf-8") == template
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def test_init_creates_parent_directories(tmp_path: Path) -> None:
|
| 48 |
"""init() creates ~/.config/free-claude-code/ even if it doesn't exist."""
|
| 49 |
config_dir = tmp_path / ".config" / "free-claude-code"
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
def test_init_copies_template_content(tmp_path: Path) -> None:
|
| 36 |
+
"""init() writes the canonical root env.example content, not an empty file."""
|
| 37 |
+
template = (Path(__file__).resolve().parents[2] / ".env.example").read_text(
|
| 38 |
+
encoding="utf-8"
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
_, env_file = _run_init(tmp_path)
|
| 41 |
|
| 42 |
assert env_file.read_text("utf-8") == template
|
| 43 |
|
| 44 |
|
| 45 |
+
def test_env_template_loader_uses_root_template_in_source_checkout() -> None:
|
| 46 |
+
"""Source checkout fallback uses the root .env.example as the single source."""
|
| 47 |
+
from cli.entrypoints import _load_env_template
|
| 48 |
+
|
| 49 |
+
template = (Path(__file__).resolve().parents[2] / ".env.example").read_text(
|
| 50 |
+
encoding="utf-8"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
assert _load_env_template() == template
|
| 54 |
+
|
| 55 |
+
|
| 56 |
def test_init_creates_parent_directories(tmp_path: Path) -> None:
|
| 57 |
"""init() creates ~/.config/free-claude-code/ even if it doesn't exist."""
|
| 58 |
config_dir = tmp_path / ".config" / "free-claude-code"
|
tests/contracts/test_architecture_contracts.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import re
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
|
|
@@ -14,12 +15,24 @@ def test_architecture_plan_exists() -> None:
|
|
| 14 |
assert "Smoke Coverage Policy" in text
|
| 15 |
|
| 16 |
|
| 17 |
-
def
|
| 18 |
repo_root = Path(__file__).resolve().parents[2]
|
| 19 |
root_example = repo_root / ".env.example"
|
| 20 |
-
|
| 21 |
|
| 22 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def test_pyproject_first_party_packages_match_packaged_roots() -> None:
|
|
@@ -35,14 +48,3 @@ def test_pyproject_first_party_packages_match_packaged_roots() -> None:
|
|
| 35 |
}
|
| 36 |
expected = {"api", "cli", "config", "core", "messaging", "providers", "smoke"}
|
| 37 |
assert configured == expected
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def _env_keys(path: Path) -> set[str]:
|
| 41 |
-
keys: set[str] = set()
|
| 42 |
-
for line in path.read_text(encoding="utf-8").splitlines():
|
| 43 |
-
stripped = line.strip()
|
| 44 |
-
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
| 45 |
-
continue
|
| 46 |
-
key, _, _value = stripped.partition("=")
|
| 47 |
-
keys.add(key.strip())
|
| 48 |
-
return keys
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import re
|
| 4 |
+
import tomllib
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
|
|
|
|
| 15 |
assert "Smoke Coverage Policy" in text
|
| 16 |
|
| 17 |
|
| 18 |
+
def test_root_env_example_is_the_single_template_source() -> None:
|
| 19 |
repo_root = Path(__file__).resolve().parents[2]
|
| 20 |
root_example = repo_root / ".env.example"
|
| 21 |
+
duplicate_example = repo_root / "config" / "env.example"
|
| 22 |
|
| 23 |
+
assert root_example.is_file()
|
| 24 |
+
assert not duplicate_example.exists()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_root_env_example_is_packaged_for_fcc_init() -> None:
|
| 28 |
+
repo_root = Path(__file__).resolve().parents[2]
|
| 29 |
+
pyproject = tomllib.loads((repo_root / "pyproject.toml").read_text("utf-8"))
|
| 30 |
+
|
| 31 |
+
force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
|
| 32 |
+
"force-include"
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
assert force_include[".env.example"] == "cli/env.example"
|
| 36 |
|
| 37 |
|
| 38 |
def test_pyproject_first_party_packages_match_packaged_roots() -> None:
|
|
|
|
| 48 |
}
|
| 49 |
expected = {"api", "cli", "config", "core", "messaging", "providers", "smoke"}
|
| 50 |
assert configured == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|