diff --git a/.env.example b/.env.example index 05f0d93d..27716f69 100644 --- a/.env.example +++ b/.env.example @@ -133,7 +133,7 @@ ALLOWED_DISCORD_CHANNELS="" # Agent Config -CLAUDE_WORKSPACE="./agent_workspace" +CLAUDE_WORKSPACE= ALLOWED_DIR="" CLAUDE_CLI_BIN="claude" FAST_PREFIX_DETECTION=true diff --git a/README.md b/README.md index 5b64fe2c..b1be0245 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ Discord minimum config: MESSAGING_PLATFORM="discord" DISCORD_BOT_TOKEN="your-discord-bot-token" ALLOWED_DISCORD_CHANNELS="123456789" -CLAUDE_WORKSPACE="./agent_workspace" +CLAUDE_WORKSPACE= ALLOWED_DIR="C:/Users/yourname/projects" ``` @@ -323,7 +323,7 @@ Telegram minimum config: MESSAGING_PLATFORM="telegram" TELEGRAM_BOT_TOKEN="123456789:ABC..." ALLOWED_TELEGRAM_USER_ID="your-user-id" -CLAUDE_WORKSPACE="./agent_workspace" +CLAUDE_WORKSPACE= ALLOWED_DIR="C:/Users/yourname/projects" ``` @@ -374,7 +374,7 @@ MODEL="nvidia_nim/z-ai/glm4.7" ANTHROPIC_AUTH_TOKEN="freecc" ``` -Config precedence is repo `.env`, then `~/.config/free-claude-code/.env`, then `FCC_ENV_FILE` when set. `ANTHROPIC_AUTH_TOKEN` can be any local secret; pass the same value to Claude Code. +Config precedence is repo `.env`, then `~/.fcc/.env`, then `FCC_ENV_FILE` when set. Blank `CLAUDE_WORKSPACE` uses `~/.fcc/agent_workspace`. `ANTHROPIC_AUTH_TOKEN` can be any local secret; pass the same value to Claude Code. ### 2. Model Routing @@ -515,7 +515,7 @@ Run them in that order before pushing. CI enforces the same checks. `pyproject.toml` installs: - `fcc-server`: starts the proxy with configured host and port. -- `fcc-init`: optional file-based config scaffold at `~/.config/free-claude-code/.env`. +- `fcc-init`: optional file-based config scaffold at `~/.fcc/.env`. - `fcc-claude`: launches Claude Code with the configured local proxy URL, auth token, and model discovery flag. - `free-claude-code`: compatibility alias for `fcc-server`. diff --git a/api/admin_config.py b/api/admin_config.py index 85f91333..edc7f40e 100644 --- a/api/admin_config.py +++ b/api/admin_config.py @@ -12,6 +12,7 @@ from typing import Any, Literal from dotenv import dotenv_values from pydantic import ValidationError +from config.paths import managed_env_path from config.provider_catalog import PROVIDER_CATALOG from config.settings import Settings @@ -34,7 +35,6 @@ SourceType = Literal[ ] MASKED_SECRET = "********" -MANAGED_ENV_RELATIVE = Path(".config") / "free-claude-code" / ".env" @dataclass(frozen=True, slots=True) @@ -486,8 +486,9 @@ FIELDS: tuple[ConfigFieldSpec, ...] = ( "Claude Workspace", "messaging", settings_attr="claude_workspace", - default="./agent_workspace", + default="", session_sensitive=True, + description="Blank uses ~/.fcc/agent_workspace.", ), ConfigFieldSpec( "ALLOWED_DIR", @@ -778,12 +779,6 @@ FIELDS: tuple[ConfigFieldSpec, ...] = ( FIELD_BY_KEY = {field.key: field for field in FIELDS} -def managed_env_path() -> Path: - """Return the admin-managed user config path.""" - - return Path.home() / MANAGED_ENV_RELATIVE - - def repo_env_path() -> Path: """Return the repo-local env path.""" diff --git a/assets/how-it-works.mmd b/assets/how-it-works.mmd index 4739004d..e5bd36f0 100644 --- a/assets/how-it-works.mmd +++ b/assets/how-it-works.mmd @@ -5,7 +5,7 @@ flowchart LR subgraph proxy["Free Claude Code Proxy :8082"] admin["Local Admin UI
keys, models, status"] - config[("Managed Config
~/.config/free-claude-code/.env")] + config[("Managed Config
~/.fcc/.env")] router{"Model Router
Opus / Sonnet / Haiku"} optimize["Request Optimizations
cheap local probes"] normalize["Protocol Normalizer
streaming, tools, thinking"] diff --git a/assets/how-it-works.svg b/assets/how-it-works.svg index 0bab3e92..281f3b44 100644 --- a/assets/how-it-works.svg +++ b/assets/how-it-works.svg @@ -1 +1 @@ -

Free Claude Code Proxy :8082

Anthropic Messages

Local Providers

LM Studio

llama.cpp

Ollama

Hosted Providers

NVIDIA NIM

Kimi

Wafer

OpenRouter

DeepSeek

Claude Code
CLI / IDE / Bots

Anthropic-compatible API
/v1/messages, /v1/models

Local Admin UI
keys, models, status

Managed Config
~/.config/free-claude-code/.env

Model Router
Opus / Sonnet / Haiku

Request Optimizations
cheap local probes

Protocol Normalizer
streaming, tools, thinking

Provider Adapters
Anthropic + OpenAI-compatible

\ No newline at end of file +

Free Claude Code Proxy :8082

Anthropic Messages

Local Providers

LM Studio

llama.cpp

Ollama

Hosted Providers

NVIDIA NIM

Kimi

Wafer

OpenRouter

DeepSeek

Claude Code
CLI / IDE / Bots

Anthropic-compatible API
/v1/messages, /v1/models

Local Admin UI
keys, models, status

Managed Config
~/.fcc/.env

Model Router
Opus / Sonnet / Haiku

Request Optimizations
cheap local probes

Protocol Normalizer
streaming, tools, thinking

Provider Adapters
Anthropic + OpenAI-compatible

\ No newline at end of file diff --git a/cli/entrypoints.py b/cli/entrypoints.py index 8e5d0a74..5ef2b8bc 100644 --- a/cli/entrypoints.py +++ b/cli/entrypoints.py @@ -21,6 +21,7 @@ from cli.process_registry import ( register_pid, unregister_pid, ) +from config.paths import config_dir_path, managed_env_path from config.settings import Settings, get_settings PROXY_PREFLIGHT_PATH = "/health" @@ -87,9 +88,9 @@ def _run_supervised_server(settings: Settings) -> bool: def init() -> None: - """Scaffold config at ~/.config/free-claude-code/.env (registered as `fcc-init`).""" - config_dir = Path.home() / ".config" / "free-claude-code" - env_file = config_dir / ".env" + """Scaffold config at ~/.fcc/.env (registered as `fcc-init`).""" + config_dir = config_dir_path() + env_file = managed_env_path() if env_file.exists(): print(f"Config already exists at {env_file}") diff --git a/config/paths.py b/config/paths.py new file mode 100644 index 00000000..0ab4e365 --- /dev/null +++ b/config/paths.py @@ -0,0 +1,25 @@ +"""Shared filesystem paths for Free Claude Code configuration.""" + +from pathlib import Path + +FCC_CONFIG_DIRNAME = ".fcc" +FCC_ENV_FILENAME = ".env" +CLAUDE_WORKSPACE_DIRNAME = "agent_workspace" + + +def config_dir_path() -> Path: + """Return the default user config directory.""" + + return Path.home() / FCC_CONFIG_DIRNAME + + +def managed_env_path() -> Path: + """Return the default user-managed env file path.""" + + return config_dir_path() / FCC_ENV_FILENAME + + +def default_claude_workspace_path() -> Path: + """Return the default Claude workspace path.""" + + return config_dir_path() / CLAUDE_WORKSPACE_DIRNAME diff --git a/config/settings.py b/config/settings.py index 9f7afe25..15f9f455 100644 --- a/config/settings.py +++ b/config/settings.py @@ -13,6 +13,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from .constants import HTTP_CONNECT_TIMEOUT_DEFAULT from .nim import NimSettings +from .paths import default_claude_workspace_path, managed_env_path from .provider_ids import SUPPORTED_PROVIDER_IDS @@ -30,7 +31,7 @@ def _env_files() -> tuple[Path, ...]: """Return env file paths in priority order (later overrides earlier).""" files: list[Path] = [ Path(".env"), - Path.home() / ".config" / "free-claude-code" / ".env", + managed_env_path(), ] if explicit := os.environ.get("FCC_ENV_FILE"): files.append(Path(explicit)) @@ -296,7 +297,10 @@ class Settings(BaseSettings): allowed_discord_channels: str | None = Field( default=None, validation_alias="ALLOWED_DISCORD_CHANNELS" ) - claude_workspace: str = "./agent_workspace" + claude_workspace: str = Field( + default_factory=lambda: str(default_claude_workspace_path()), + validation_alias="CLAUDE_WORKSPACE", + ) allowed_dir: str = "" claude_cli_bin: str = Field(default="claude", validation_alias="CLAUDE_CLI_BIN") max_message_log_entries_per_chat: int | None = Field( @@ -348,6 +352,13 @@ class Settings(BaseSettings): return None return v + @field_validator("claude_workspace", mode="before") + @classmethod + def default_blank_claude_workspace(cls, v: Any) -> Any: + if v == "" or v is None: + return str(default_claude_workspace_path()) + return v + @field_validator("whisper_device") @classmethod def validate_whisper_device(cls, v: str) -> str: diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index 7a58ed9f..80f1064e 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -102,7 +102,7 @@ def test_admin_apply_writes_complete_managed_env_and_masks_preview( body = response.json() assert body["applied"] is True assert "OPENROUTER_API_KEY=********" in body["env_preview"] - env_file = tmp_path / ".config" / "free-claude-code" / ".env" + env_file = tmp_path / ".fcc" / ".env" text = env_file.read_text("utf-8") assert "MODEL=open_router/test-model" in text assert "OPENROUTER_API_KEY=router-secret" in text @@ -183,7 +183,7 @@ def test_admin_process_env_values_are_locked_and_not_written(monkeypatch, tmp_pa ) assert response.status_code == 200 - env_file = tmp_path / ".config" / "free-claude-code" / ".env" + env_file = tmp_path / ".fcc" / ".env" assert "deepseek/managed-model" not in env_file.read_text("utf-8") @@ -208,9 +208,7 @@ def test_admin_first_apply_migrates_repo_env(monkeypatch, tmp_path): ) assert response.status_code == 200 - managed_text = (tmp_path / ".config" / "free-claude-code" / ".env").read_text( - "utf-8" - ) + managed_text = (tmp_path / ".fcc" / ".env").read_text("utf-8") assert "MODEL=deepseek/deepseek-chat" in managed_text assert "DEEPSEEK_API_KEY=deepseek-secret" in managed_text diff --git a/tests/cli/test_entrypoints.py b/tests/cli/test_entrypoints.py index 6b29c0c0..5b0831db 100644 --- a/tests/cli/test_entrypoints.py +++ b/tests/cli/test_entrypoints.py @@ -28,7 +28,7 @@ def _run_init(tmp_home: Path) -> tuple[str, Path]: """Run init() with home directory redirected to tmp_home. Returns (printed output, env_file path).""" from cli.entrypoints import init - env_file = tmp_home / ".config" / "free-claude-code" / ".env" + env_file = tmp_home / ".fcc" / ".env" printed: list[str] = [] with ( @@ -74,8 +74,8 @@ def test_env_template_loader_uses_root_template_in_source_checkout() -> None: def test_init_creates_parent_directories(tmp_path: Path) -> None: - """init() creates ~/.config/free-claude-code/ even if it doesn't exist.""" - config_dir = tmp_path / ".config" / "free-claude-code" + """init() creates ~/.fcc/ even if it doesn't exist.""" + config_dir = tmp_path / ".fcc" assert not config_dir.exists() _run_init(tmp_path) @@ -88,7 +88,7 @@ def test_init_skips_if_env_already_exists(tmp_path: Path) -> None: # Create it first _run_init(tmp_path) - env_file = tmp_path / ".config" / "free-claude-code" / ".env" + env_file = tmp_path / ".fcc" / ".env" env_file.write_text("existing content", encoding="utf-8") output, _ = _run_init(tmp_path) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 3794c0b3..c71002f9 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -24,6 +24,7 @@ class TestSettings: """Test default values are set and have correct types.""" from config.settings import Settings + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) monkeypatch.delenv("MODEL", raising=False) monkeypatch.delenv("HTTP_READ_TIMEOUT", raising=False) monkeypatch.delenv("HTTP_CONNECT_TIMEOUT", raising=False) @@ -43,6 +44,44 @@ class TestSettings: assert settings.debug_platform_edits is False assert settings.debug_subagent_stack is False + def test_default_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """Blank/unset CLAUDE_WORKSPACE stores agent data under ~/.fcc.""" + from config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("CLAUDE_WORKSPACE", raising=False) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") + + def test_blank_claude_workspace_uses_fcc_home(self, monkeypatch, tmp_path): + """An explicit blank env value keeps the default workspace path.""" + from config.settings import Settings + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("CLAUDE_WORKSPACE", "") + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert settings.claude_workspace == str(tmp_path / ".fcc" / "agent_workspace") + + def test_explicit_claude_workspace_is_preserved(self, monkeypatch, tmp_path): + """Custom CLAUDE_WORKSPACE values are not rewritten.""" + from config.settings import Settings + + workspace = tmp_path / "custom-workspace" + monkeypatch.setenv("CLAUDE_WORKSPACE", str(workspace)) + monkeypatch.setitem(Settings.model_config, "env_file", ()) + + settings = Settings() + + assert settings.claude_workspace == str(workspace) + def test_get_settings_cached(self): """Test get_settings returns cached instance.""" from config.settings import get_settings