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
2 changes: 2 additions & 0 deletions arm_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from arm_cli import __version__
from arm_cli.config import load_config
from arm_cli.container.container import container
from arm_cli.projects.projects import projects
from arm_cli.self.self import self
from arm_cli.system.system import system

Expand All @@ -24,6 +25,7 @@ def cli(ctx):

# Add command groups
cli.add_command(container)
cli.add_command(projects)
cli.add_command(self)
cli.add_command(system)

Expand Down
193 changes: 192 additions & 1 deletion arm_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import json
import shutil
from pathlib import Path
from typing import Dict, List, Optional

import appdirs
from pydantic import BaseModel


class ProjectConfig(BaseModel):
"""Configuration schema for individual projects."""

name: str
description: Optional[str] = None
project_directory: Optional[str] = None
docker_compose_file: Optional[str] = None
data_directory: Optional[str] = None


class AvailableProject(BaseModel):
"""Schema for available project entry."""

name: str
path: str


class Config(BaseModel):
"""Configuration schema for the CLI."""

active_project: str = ""
available_projects: List[AvailableProject] = []


def get_config_dir() -> Path:
Expand All @@ -23,6 +43,134 @@ def get_config_file() -> Path:
return get_config_dir() / "config.json"


def get_default_project_config_path() -> Path:
"""Get the path to the default project configuration in the repository."""
# First try to find it relative to the current file (development)
current_file = Path(__file__)
dev_path = current_file.parent.parent / "resources" / "default_project_config.json"

if dev_path.exists():
return dev_path

# If not found in development, try to find it in the installed package
try:
import arm_cli

package_dir = Path(arm_cli.__file__).parent
# When installed, the resources directory is at the root level of the package
installed_path = package_dir.parent / "resources" / "default_project_config.json"

if installed_path.exists():
return installed_path
except ImportError:
pass

# Fallback: try to find it in the current working directory
cwd_path = Path.cwd() / "resources" / "default_project_config.json"
if cwd_path.exists():
return cwd_path

raise FileNotFoundError("Could not find default_project_config.json in any expected location")


def copy_default_project_config() -> Path:
"""Copy the default project config from repository to user config directory."""
config_dir = get_config_dir()
default_config_path = get_default_project_config_path()
user_config_path = config_dir / "default_project_config.json"

if not default_config_path.exists():
raise FileNotFoundError(f"Default project config not found at {default_config_path}")

shutil.copy2(default_config_path, user_config_path)
return user_config_path


def load_project_config(project_path: str) -> ProjectConfig:
"""Load a project configuration from file."""
config_path = Path(project_path)

# If it's a relative path, make it relative to the config directory
if not config_path.is_absolute():
config_path = get_config_dir() / config_path

if not config_path.exists():
raise FileNotFoundError(f"Project config not found at {config_path}")

with open(config_path, "r") as f:
data = json.load(f)
return ProjectConfig(**data)


def add_project_to_list(config: Config, 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]

# Add new entry
project_entry = AvailableProject(name=project_name, path=project_path)
config.available_projects.append(project_entry)

# Set as active project
config.active_project = project_path


def get_available_projects(config: Config) -> List[AvailableProject]:
"""Get the list of available projects."""
# If no projects are available, ensure the default project is added
if not config.available_projects:
try:
default_path = copy_default_project_config()
default_project_config = load_project_config(str(default_path))
add_project_to_list(config, str(default_path), default_project_config.name)
except Exception as e:
print(f"Error setting up default project: {e}")

return config.available_projects


def activate_project(config: Config, 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:
if project.path == project_identifier:
config.active_project = project.path
save_config(config)
return load_project_config(project.path)

# Try to find by name
for project in config.available_projects:
if project.name.lower() == project_identifier.lower():
config.active_project = project.path
save_config(config)
return load_project_config(project.path)

return None


def remove_project_from_list(config: Config, 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:
if project.path == project_identifier:
# If this is the active project, clear the active project
if config.active_project == project.path:
config.active_project = ""
config.available_projects.remove(project)
return True

# Try to find by name
for project in config.available_projects:
if project.name.lower() == project_identifier.lower():
# If this is the active project, clear the active project
if config.active_project == project.path:
config.active_project = ""
config.available_projects.remove(project)
return True

return False


def load_config() -> Config:
"""Load configuration from file, creating default if it doesn't exist."""
config_file = get_config_file()
Expand All @@ -39,7 +187,7 @@ def load_config() -> Config:
return Config(**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: " f"{e}")
print(f"Warning: Config file corrupted, creating new default config: {e}")
default_config = Config()
save_config(default_config)
return default_config
Expand All @@ -54,3 +202,46 @@ def save_config(config: Config) -> None:

with open(config_file, "w") as f:
json.dump(config.model_dump(), f, indent=2)


def get_active_project_config(config: Config) -> Optional[ProjectConfig]:
"""Get the active project configuration."""
if not config.active_project:
# No active project set, copy default and set it
try:
default_path = copy_default_project_config()
config.active_project = str(default_path)

# Also add the default project to available projects if not already there
default_project_config = load_project_config(str(default_path))
add_project_to_list(config, str(default_path), default_project_config.name)

return default_project_config
except Exception as e:
print(f"Error setting up default project config: {e}")
return None

try:
return load_project_config(config.active_project)
except FileNotFoundError:
print(f"Active project config not found at {config.active_project}")
return None
except IsADirectoryError:
print(f"Active project config path is a directory: {config.active_project}")
return None


def print_no_projects_message() -> None:
"""Print the standard message when no projects are available."""
print("No projects available. Use 'arm-cli projects init <path>' to add a project.")


def print_available_projects(config: Config) -> None:
"""Print the list of available projects."""
available_projects = get_available_projects(config)
if available_projects:
print("Available projects:")
for i, proj in enumerate(available_projects, 1):
print(f" {i}. {proj.name} ({proj.path})")
else:
print_no_projects_message()
2 changes: 1 addition & 1 deletion arm_cli/container/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
@click.group()
def container():
"""Basic tools for managing Docker containers. For more extensive tooling,
try lazydocker"""
try lazydocker (brew install jesseduffield/lazydocker/lazydocker)"""
pass


Expand Down
3 changes: 3 additions & 0 deletions arm_cli/projects/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Projects module for ARM CLI

from . import activate, info, init, list, remove
77 changes: 77 additions & 0 deletions arm_cli/projects/activate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from typing import Optional

import click
import inquirer

from arm_cli.config import (
activate_project,
get_active_project_config,
get_available_projects,
print_available_projects,
print_no_projects_message,
)


def _activate(ctx, project: Optional[str] = None):
"""Activate a project from available projects"""
config = ctx.obj["config"]

# If no project specified, show interactive list
if project is None:
available_projects = get_available_projects(config)

if not available_projects:
print("No projects available. Setting up default project...")
project_config = get_active_project_config(config)
if project_config:
print(f"Activated default project: {project_config.name}")
print(f"Project directory: {project_config.project_directory}")
else:
print("Failed to set up default project.")
print_no_projects_message()
return

# Create choices for inquirer
choices = []
for proj in available_projects:
active_indicator = " *" if proj.path == config.active_project else ""
choices.append(f"{proj.name}{active_indicator}")

# Create the question
questions = [
inquirer.List(
"project", message="Select a project to activate", choices=choices, carousel=True
)
]

try:
answers = inquirer.prompt(questions)
if answers is None:
print("Cancelled.")
return

# Extract project name (remove the active indicator if present)
selected_choice = answers["project"]
project = selected_choice.replace(" *", "")

except KeyboardInterrupt:
print("\nCancelled.")
return

# Try to activate the project
# At this point, project is guaranteed to be a string
assert project is not None # type guard
project_config = activate_project(config, project)

if project_config:
print(f"Activated project: {project_config.name}")
print(f"Project directory: {project_config.project_directory}")
else:
print(f"Project '{project}' not found in available projects")
print_available_projects(config)


# Create the command object
activate = click.command(name="activate")(
click.argument("project", required=False)(click.pass_context(_activate))
)
57 changes: 57 additions & 0 deletions arm_cli/projects/info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import click

from arm_cli.config import get_active_project_config


def _info(ctx, field):
"""Show information about the active project"""
config = ctx.obj["config"]

# Get the active project configuration
project_config = get_active_project_config(config)
if not project_config:
print("No active project configured.")
return

# If --field is specified, extract and print only that field
if field:
# Convert field name to attribute name (e.g., "project_directory" -> project_directory)
field = field.lower().replace(" ", "_")

# Get all public attributes (not starting with _)
available_attrs = [
attr
for attr in dir(project_config)
if not attr.startswith("_") and not callable(getattr(project_config, attr))
]

if field in available_attrs:
value = getattr(project_config, field)
if value:
print(value)
else:
print("", end="") # Print empty string for empty values
else:
print(f"Unknown field: {field}", file=click.get_text_stream("stderr"))
print(
f"Available fields: {', '.join(available_attrs)}",
file=click.get_text_stream("stderr"),
)
return
else:
# Print all fields as before
print(f"Active Project: {project_config.name}")
if project_config.description:
print(f"Description: {project_config.description}")
if project_config.project_directory:
print(f"Project Directory: {project_config.project_directory}")
if project_config.docker_compose_file:
print(f"Docker Compose File: {project_config.docker_compose_file}")
if project_config.data_directory:
print(f"Data Directory: {project_config.data_directory}")


# Create the command object
info = click.command(name="info")(
click.option("--field", help="Extract a specific field value")(click.pass_context(_info))
)
Loading