diff --git a/arm_cli/config.py b/arm_cli/config.py index 8d08adc..4aeb483 100644 --- a/arm_cli/config.py +++ b/arm_cli/config.py @@ -57,8 +57,8 @@ class AvailableProject(BaseModel): path: str -class Config(BaseModel): - """Configuration schema for the CLI.""" +class GlobalContext(BaseModel): + """Global context schema for the CLI.""" active_project: str = "" available_projects: List[AvailableProject] = [] @@ -73,7 +73,12 @@ def get_config_dir() -> Path: def get_config_file() -> Path: """Get the path to the configuration file.""" - return get_config_dir() / "config.json" + from arm_cli.settings import get_setting + + # Get the filename from settings, default to "global_context.json" + filename_setting = get_setting("global_context_path") + filename = filename_setting if isinstance(filename_setting, str) else "global_context.json" + return get_config_dir() / filename def get_default_project_config_path() -> Path: @@ -141,7 +146,7 @@ def load_project_config(project_path: str) -> ProjectConfig: return project_config -def add_project_to_list(config: Config, project_path: str, project_name: str) -> None: +def add_project_to_list(config: GlobalContext, project_path: str, project_name: str) -> None: """Add a project to the available projects list and set as active.""" # Remove existing entry if it exists config.available_projects = [p for p in config.available_projects if p.path != project_path] @@ -154,7 +159,7 @@ def add_project_to_list(config: Config, project_path: str, project_name: str) -> config.active_project = project_path -def get_available_projects(config: Config) -> List[AvailableProject]: +def get_available_projects(config: GlobalContext) -> List[AvailableProject]: """Get the list of available projects.""" # If no projects are available, ensure the default project is added if not config.available_projects: @@ -168,7 +173,7 @@ def get_available_projects(config: Config) -> List[AvailableProject]: return config.available_projects -def activate_project(config: Config, project_identifier: str) -> Optional[ProjectConfig]: +def activate_project(config: GlobalContext, project_identifier: str) -> Optional[ProjectConfig]: """Activate a project by path or name.""" # First try to find by exact path for project in config.available_projects: @@ -187,7 +192,7 @@ def activate_project(config: Config, project_identifier: str) -> Optional[Projec return None -def remove_project_from_list(config: Config, project_identifier: str) -> bool: +def remove_project_from_list(config: GlobalContext, project_identifier: str) -> bool: """Remove a project from the available projects list by path or name.""" # First try to find by exact path for project in config.available_projects: @@ -210,29 +215,45 @@ def remove_project_from_list(config: Config, project_identifier: str) -> bool: return False -def load_config() -> Config: +def load_config() -> GlobalContext: """Load configuration from file, creating default if it doesn't exist.""" config_file = get_config_file() + # Check for old config file and migrate if needed + old_config_file = get_config_dir() / "config.json" + if old_config_file.exists() and not config_file.exists(): + print(f"Migrating from old config file: {old_config_file}") + try: + with open(old_config_file, "r") as f: + data = json.load(f) + config = GlobalContext(**data) + save_config(config) + # Remove old file after successful migration + old_config_file.unlink() + print(f"Migration complete. New file: {config_file}") + return config + except Exception as e: + print(f"Migration failed: {e}. Creating new config file.") + if not config_file.exists(): # Create default config - default_config = Config() + default_config = GlobalContext() save_config(default_config) return default_config try: with open(config_file, "r") as f: data = json.load(f) - return Config(**data) + return GlobalContext(**data) except (json.JSONDecodeError, KeyError, TypeError) as e: # If config is corrupted, create a new one print(f"Warning: Config file corrupted, creating new default config: {e}") - default_config = Config() + default_config = GlobalContext() save_config(default_config) return default_config -def save_config(config: Config) -> None: +def save_config(config: GlobalContext) -> None: """Save configuration to file.""" config_file = get_config_file() @@ -243,7 +264,7 @@ def save_config(config: Config) -> None: json.dump(config.model_dump(), f, indent=2) -def get_active_project_config(config: Config) -> Optional[ProjectConfig]: +def get_active_project_config(config: GlobalContext) -> Optional[ProjectConfig]: """Get the active project configuration.""" if not config.active_project: # No active project set, copy default and set it @@ -275,7 +296,7 @@ def print_no_projects_message() -> None: print("No projects available. Use 'arm-cli projects init ' to add a project.") -def print_available_projects(config: Config) -> None: +def print_available_projects(config: GlobalContext) -> None: """Print the list of available projects.""" available_projects = get_available_projects(config) if available_projects: diff --git a/arm_cli/container/container.py b/arm_cli/container/container.py index eac6fde..396e8de 100644 --- a/arm_cli/container/container.py +++ b/arm_cli/container/container.py @@ -4,6 +4,8 @@ import docker import inquirer +from arm_cli.settings import get_setting + @click.group() def container(): @@ -49,6 +51,7 @@ def attach_container(ctx): message="Select a container to attach to", choices=[f"{container.name} ({container.id[:12]})" for container in containers], carousel=True, + page_size=get_setting("menu_page_size"), ) ] @@ -82,6 +85,7 @@ def restart_container(ctx): message="Select a container to restart", choices=[f"{container.name} ({container.id[:12]})" for container in containers], carousel=True, + page_size=get_setting("menu_page_size"), ) ] @@ -122,6 +126,7 @@ def stop_container(ctx): message="Select a container to stop", choices=[f"{container.name} ({container.id[:12]})" for container in containers], carousel=True, + page_size=get_setting("menu_page_size"), ) ] diff --git a/arm_cli/projects/activate.py b/arm_cli/projects/activate.py index 0787aa9..649777d 100644 --- a/arm_cli/projects/activate.py +++ b/arm_cli/projects/activate.py @@ -10,6 +10,7 @@ print_available_projects, print_no_projects_message, ) +from arm_cli.settings import get_setting def _activate(ctx, project: Optional[str] = None): @@ -43,7 +44,11 @@ def _activate(ctx, project: Optional[str] = None): # Create the question questions = [ inquirer.List( - "project", message="Select a project to activate", choices=choices, carousel=True + "project", + message="Select a project to activate", + choices=choices, + carousel=True, + page_size=get_setting("menu_page_size"), ) ] diff --git a/arm_cli/projects/init.py b/arm_cli/projects/init.py index 9ddb1b9..dac3156 100644 --- a/arm_cli/projects/init.py +++ b/arm_cli/projects/init.py @@ -12,6 +12,7 @@ load_project_config, save_config, ) +from arm_cli.settings import get_setting def _init(ctx, project_path: str, name: Optional[str] = None): @@ -84,6 +85,7 @@ def _init(ctx, project_path: str, name: Optional[str] = None): message=f"Select a configuration file from {project_dir.name} or use default", choices=choices, carousel=True, + page_size=get_setting("menu_page_size"), ) ] diff --git a/arm_cli/projects/remove.py b/arm_cli/projects/remove.py index d8d289f..6d4b91f 100644 --- a/arm_cli/projects/remove.py +++ b/arm_cli/projects/remove.py @@ -10,6 +10,7 @@ remove_project_from_list, save_config, ) +from arm_cli.settings import get_setting def _remove(ctx, project: Optional[str] = None): @@ -33,7 +34,11 @@ def _remove(ctx, project: Optional[str] = None): # Create the question questions = [ inquirer.List( - "project", message="Select a project to remove", choices=choices, carousel=True + "project", + message="Select a project to remove", + choices=choices, + carousel=True, + page_size=get_setting("menu_page_size"), ) ] diff --git a/arm_cli/self/self.py b/arm_cli/self/self.py index 44790af..4f7d48e 100644 --- a/arm_cli/self/self.py +++ b/arm_cli/self/self.py @@ -4,6 +4,7 @@ from pathlib import Path import click +import inquirer from click.core import ParameterSource from arm_cli.config import ( @@ -12,6 +13,7 @@ load_project_config, save_config, ) +from arm_cli.settings import get_setting, load_settings, save_settings, set_setting @click.group() @@ -65,79 +67,166 @@ def update(ctx, source, force): print("arm-cli updated successfully!") -@self.command() -@click.option("--project", help="Set the active project config file") -@click.option("--list-projects", is_flag=True, help="List all available project config files") -@click.option("--show-project", is_flag=True, help="Show current project configuration") +@self.group() +def settings(): + """Manage CLI settings""" + pass + + +@settings.command("show") +@click.pass_context +def show_settings(ctx): + """Show the settings file path and contents""" + from arm_cli.settings import get_settings_file + + settings_file = get_settings_file() + print(f"Settings file: {settings_file}") + print() + + if settings_file.exists(): + with open(settings_file, "r") as f: + print(f.read()) + else: + print("Settings file does not exist yet.") + + +@settings.command("get") +@click.argument("key") +@click.pass_context +def get_settings_cmd(ctx, key): + """Get a specific setting value""" + from arm_cli.settings import get_setting + + value = get_setting(key) + if value is not None: + print(value) + else: + print(f"Error: Unknown setting '{key}'") + # Get available settings to show user + from arm_cli.settings import load_settings + + settings = load_settings() + print(f"Available settings: {', '.join(settings.model_fields.keys())}") + sys.exit(1) + + +@settings.command("set") +@click.argument("key", required=False) +@click.argument("value", required=False) @click.pass_context -def config(ctx, project, list_projects, show_project): - """Manage CLI configuration""" - config = ctx.obj["config"] - - if list_projects: - config_dir = get_config_dir() - print("Available project config files:") - for config_file in config_dir.glob("*.json"): - if config_file.name != "config.json": # Skip the main config file - active_marker = " (active)" if str(config_file) == config.active_project else "" - print(f" {config_file.name}{active_marker}") - - # Try to load and show description - try: - project_config = load_project_config(str(config_file)) - if project_config.description: - print(f" Description: {project_config.description}") - except Exception: - print(f" (Could not load config)") +def set_settings(ctx, key, value): + """Set a configuration value""" + from arm_cli.settings import get_setting, load_settings, save_settings, set_setting + + settings = load_settings() + + # If no arguments provided, launch interactive mode + if key is None and value is None: + # Get all available settings + available_settings = list(settings.model_fields.keys()) + + # Create choices for inquirer + choices = [] + for setting_key in available_settings: + current_val = getattr(settings, setting_key) + choices.append(f"{setting_key} (current: {current_val})") + + # Ask user to select a setting + questions = [ + inquirer.List( + "setting", + message="Select a setting to modify", + choices=choices, + carousel=True, + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Configuration cancelled.") + return + + # Extract the key from the selected choice + selected_choice = answers["setting"] + key = selected_choice.split(" (current:")[0] + + except KeyboardInterrupt: + print("\nConfiguration cancelled.") + return + + # If only key is provided, ask for value + if key is not None and value is None: + current_value = getattr(settings, key) + + # Create appropriate input question based on type + if isinstance(current_value, bool): + questions = [ + inquirer.List( + "value", + message=f"Set {key} (current: {current_value})", + choices=["true", "false"], + default="true" if current_value else "false", + carousel=True, + ) + ] + else: + questions = [ + inquirer.Text( + "value", + message=f"Set {key} (current: {current_value})", + default=str(current_value), + ) + ] + + try: + answers = inquirer.prompt(questions) + if answers is None: + print("Configuration cancelled.") + return + + value = answers["value"] + + except KeyboardInterrupt: + print("\nConfiguration cancelled.") + return + + # Now we have both key and value, proceed with validation and setting + if key is None or value is None: + print("Error: Both key and value are required.") + return + + # Check if the key exists in settings + if not hasattr(settings, key): + print(f"Error: Unknown setting '{key}'") + print(f"Available settings: {', '.join(settings.model_fields.keys())}") return - if show_project: - project_config = get_active_project_config(config) - if project_config: - print(f"Active project: {project_config.name}") - print(f"Config file: {config.active_project}") - print(f"Description: {project_config.description or 'No description'}") - print(f"Docker compose file: {project_config.docker_compose_file or 'Not specified'}") - print(f"Data directory: {project_config.data_directory or 'Not specified'}") - - if project_config.resources: - print("Resources:") - for resource_name, resource_path in project_config.resources.items(): - print(f" {resource_name}: {resource_path}") - - if project_config.skills: - print("Skills:") - for skill_name, skill_path in project_config.skills.items(): - print(f" {skill_name}: {skill_path}") - - if project_config.monitoring: - print("Monitoring:") - for service_name, service_url in project_config.monitoring.items(): - print(f" {service_name}: {service_url}") + # Get the current value + current_value = getattr(settings, key) + + # Convert value to the appropriate type based on the current value + try: + if isinstance(current_value, int): + new_value = int(value) + elif isinstance(current_value, bool): + if value.lower() in ("true", "1", "yes", "on"): + new_value = True + elif value.lower() in ("false", "0", "no", "off"): + new_value = False + else: + print( + f"Error: Invalid boolean value '{value}'. Use true/false, 1/0, yes/no, or on/off" + ) + return else: - print("No active project configuration found.") + new_value = value + except ValueError as e: + print(f"Error: Invalid value '{value}' for setting '{key}': {e}") return - if project is not None: - # Check if the project file exists - project_path = Path(project) - if not project_path.is_absolute(): - project_path = get_config_dir() / project_path - - if not project_path.exists(): - print(f"Error: Project config file '{project}' not found.") - print("Available project config files:") - config_dir = get_config_dir() - for config_file in config_dir.glob("*.json"): - if config_file.name != "config.json": - print(f" {config_file.name}") - return + # Set the new value + setattr(settings, key, new_value) + save_settings(settings) - config.active_project = str(project_path) - save_config(config) - print(f"Active project set to: {project}") - else: - print(f"Active project: {config.active_project or 'None (will use default)'}") - print("Use --project to set a new active project") - print("Use --list-projects to see all available project config files") - print("Use --show-project to see current project configuration") + print(f"Updated {key}: {current_value} → {new_value}") diff --git a/arm_cli/settings.py b/arm_cli/settings.py new file mode 100644 index 0000000..7199a6e --- /dev/null +++ b/arm_cli/settings.py @@ -0,0 +1,76 @@ +import json +import os +from pathlib import Path +from typing import Optional, Union + +import appdirs +from pydantic import BaseModel + + +class Settings(BaseModel): + """Settings schema for the CLI.""" + + menu_page_size: int = 20 + global_context_path: str = "global_context.json" + cdc_path: str = "~/code" + + +def get_settings_dir() -> Path: + """Get the settings directory for the CLI.""" + settings_dir = Path(appdirs.user_config_dir("arm-cli")) + settings_dir.mkdir(parents=True, exist_ok=True) + return settings_dir + + +def get_settings_file() -> Path: + """Get the path to the settings file.""" + return get_settings_dir() / "settings.json" + + +def load_settings() -> Settings: + """Load settings from file, creating default if it doesn't exist.""" + settings_file = get_settings_file() + + if not settings_file.exists(): + # Create default settings + default_settings = Settings() + save_settings(default_settings) + return default_settings + + try: + with open(settings_file, "r") as f: + data = json.load(f) + return Settings(**data) + except (json.JSONDecodeError, KeyError, TypeError) as e: + # If settings file is corrupted, create a new one + print(f"Warning: Settings file corrupted, creating new default settings: {e}") + default_settings = Settings() + save_settings(default_settings) + return default_settings + + +def save_settings(settings: Settings) -> None: + """Save settings to file.""" + settings_file = get_settings_file() + + # Ensure directory exists + settings_file.parent.mkdir(parents=True, exist_ok=True) + + with open(settings_file, "w") as f: + json.dump(settings.model_dump(), f, indent=2) + + +def get_setting(key: str) -> Optional[Union[int, str, bool]]: + """Get a specific setting value.""" + settings = load_settings() + return getattr(settings, key, None) + + +def set_setting(key: str, value: Union[int, str, bool]) -> None: + """Set a specific setting value.""" + settings = load_settings() + if hasattr(settings, key): + setattr(settings, key, value) + save_settings(settings) + else: + raise ValueError(f"Unknown setting: {key}") diff --git a/arm_cli/system/shell_scripts/shell_addins.sh b/arm_cli/system/shell_scripts/shell_addins.sh index da032cc..26f763a 100644 --- a/arm_cli/system/shell_scripts/shell_addins.sh +++ b/arm_cli/system/shell_scripts/shell_addins.sh @@ -39,6 +39,9 @@ setup_alias() { # Add cdp alias to change to project directory alias cdp='cd "$(arm-cli projects info --field "project_directory" | sed "s|^~|$HOME|")"' + + # Add cdc alias to change to code directory + alias cdc='cd "$(arm-cli self settings get cdc_path | sed "s|^~|$HOME|")"' fi fi } diff --git a/tests/test_config.py b/tests/test_config.py index 033b00d..7e752ff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,7 @@ import pytest from arm_cli.config import ( - Config, + GlobalContext, ProjectConfig, get_config_dir, get_config_file, @@ -16,20 +16,20 @@ ) -class TestConfig: - def test_config_default_values(self): - """Test that Config has correct default values.""" - config = Config() +class TestGlobalContext: + def test_global_context_default_values(self): + """Test that GlobalContext has correct default values.""" + config = GlobalContext() assert config.active_project == "" - def test_config_with_values(self): - """Test that Config can be created with custom values.""" - config = Config(active_project="test-project") + def test_global_context_with_values(self): + """Test that GlobalContext can be created with custom values.""" + config = GlobalContext(active_project="test-project") assert config.active_project == "test-project" - def test_config_model_dump(self): - """Test that Config can be serialized to dict.""" - config = Config(active_project="test-project") + def test_global_context_model_dump(self): + """Test that GlobalContext can be serialized to dict.""" + config = GlobalContext(active_project="test-project") data = config.model_dump() assert data == {"active_project": "test-project", "available_projects": []} @@ -52,7 +52,7 @@ def test_get_config_file(self): config_file = get_config_file() - assert config_file == Path("/tmp/test_config/config.json") + assert config_file == Path("/tmp/test_config/global_context.json") def test_save_config(self): """Test that config can be saved to file.""" @@ -61,7 +61,7 @@ def test_save_config(self): config_file = Path(temp_dir) / "config.json" mock_get_config_file.return_value = config_file - config = Config(active_project="test-project") + config = GlobalContext(active_project="test-project") save_config(config) assert config_file.exists() diff --git a/tests/unit/projects/test_projects.py b/tests/unit/projects/test_projects.py index 6c2d6ac..ba438ad 100644 --- a/tests/unit/projects/test_projects.py +++ b/tests/unit/projects/test_projects.py @@ -7,7 +7,7 @@ import pytest from click.testing import CliRunner -from arm_cli.config import Config, ProjectConfig +from arm_cli.config import GlobalContext, ProjectConfig from arm_cli.projects.projects import projects @@ -33,7 +33,7 @@ def temp_project_config(): @pytest.fixture def mock_config(temp_project_config): """Create a mock config with an active project.""" - config = Config(active_project="/tmp/test-project-config.json") + config = GlobalContext(active_project="/tmp/test-project-config.json") return config @@ -42,7 +42,7 @@ def test_projects_info_no_active_project(runner): with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = None - result = runner.invoke(projects, ["info"], obj={"config": Config()}) + result = runner.invoke(projects, ["info"], obj={"config": GlobalContext()}) assert result.exit_code == 0 assert "No active project configured" in result.output @@ -191,7 +191,9 @@ def test_projects_info_field_no_active_project(runner): with patch("arm_cli.projects.info.get_active_project_config") as mock_get_config: mock_get_config.return_value = None - result = runner.invoke(projects, ["info", "--field", "name"], obj={"config": Config()}) + result = runner.invoke( + projects, ["info", "--field", "name"], obj={"config": GlobalContext()} + ) assert result.exit_code == 0 assert "No active project configured" in result.output