diff --git a/src/crewai/cli/config.py b/src/crewai/cli/config.py index d1c2ba725..e4ed1fad5 100644 --- a/src/crewai/cli/config.py +++ b/src/crewai/cli/config.py @@ -1,4 +1,6 @@ import json +import tempfile +from logging import getLogger from pathlib import Path from pydantic import BaseModel, Field @@ -12,8 +14,48 @@ from crewai.cli.constants import ( ) from crewai.cli.shared.token_manager import TokenManager +logger = getLogger(__name__) + DEFAULT_CONFIG_PATH = Path.home() / ".config" / "crewai" / "settings.json" + +def get_writable_config_path() -> Path | None: + """ + Find a writable location for the config file with fallback options. + + Tries in order: + 1. Default: ~/.config/crewai/settings.json + 2. Temp directory: /tmp/crewai_settings.json (or OS equivalent) + 3. Current directory: ./crewai_settings.json + 4. In-memory only (returns None) + + Returns: + Path object for writable config location, or None if no writable location found + """ + fallback_paths = [ + DEFAULT_CONFIG_PATH, # Default location + Path(tempfile.gettempdir()) / "crewai_settings.json", # Temporary directory + Path.cwd() / "crewai_settings.json", # Current working directory + ] + + for config_path in fallback_paths: + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + test_file = config_path.parent / ".crewai_write_test" + try: + test_file.write_text("test") + test_file.unlink() # Clean up test file + logger.info(f"Using config path: {config_path}") + return config_path + except Exception: # noqa: S112 + continue + + except Exception: # noqa: S112 + continue + + return None + + # Settings that are related to the user's account USER_SETTINGS_KEYS = [ "tool_repository_username", @@ -93,16 +135,32 @@ class Settings(BaseModel): default=DEFAULT_CLI_SETTINGS["oauth2_domain"], ) - def __init__(self, config_path: Path = DEFAULT_CONFIG_PATH, **data): - """Load Settings from config path""" - config_path.parent.mkdir(parents=True, exist_ok=True) + def __init__(self, config_path: Path | None = None, **data): + """Load Settings from config path with fallback support""" + if config_path is None: + config_path = get_writable_config_path() + + # If config_path is None, we're in memory-only mode + if config_path is None: + merged_data = {**data} + # Dummy path for memory-only mode + super().__init__(config_path=Path("/dev/null"), **merged_data) + return + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + except Exception: + merged_data = {**data} + # Dummy path for memory-only mode + super().__init__(config_path=Path("/dev/null"), **merged_data) + return file_data = {} if config_path.is_file(): try: with config_path.open("r") as f: file_data = json.load(f) - except json.JSONDecodeError: + except Exception: file_data = {} merged_data = {**file_data, **data} @@ -122,15 +180,22 @@ class Settings(BaseModel): def dump(self) -> None: """Save current settings to settings.json""" - if self.config_path.is_file(): - with self.config_path.open("r") as f: - existing_data = json.load(f) - else: - existing_data = {} + if str(self.config_path) == "/dev/null": + return - updated_data = {**existing_data, **self.model_dump(exclude_unset=True)} - with self.config_path.open("w") as f: - json.dump(updated_data, f, indent=4) + try: + if self.config_path.is_file(): + with self.config_path.open("r") as f: + existing_data = json.load(f) + else: + existing_data = {} + + updated_data = {**existing_data, **self.model_dump(exclude_unset=True)} + with self.config_path.open("w") as f: + json.dump(updated_data, f, indent=4) + + except Exception: # noqa: S110 + pass def _reset_user_settings(self) -> None: """Reset all user settings to default values"""