"""Configuration commands for AITBC CLI""" import click import os import shlex import subprocess import yaml import json from pathlib import Path from typing import Optional, Dict, Any from config import get_config, Config from utils import output, error, success @click.group() def config(): """Manage CLI configuration""" pass @config.command() @click.pass_context def show(ctx): """Show current configuration""" config = ctx.obj['config'] config_dict = { "coordinator_url": config.coordinator_url, "api_key": "***REDACTED***" if config.api_key else None, "timeout": getattr(config, 'timeout', 30), "config_file": getattr(config, 'config_file', None) } output(config_dict, ctx.obj['output_format']) @config.command() @click.argument("key") @click.argument("value") @click.option("--global", "global_config", is_flag=True, help="Set global config") @click.pass_context def set(ctx, key: str, value: str, global_config: bool): """Set configuration value""" config = ctx.obj['config'] # Determine config file path if global_config: config_dir = Path.home() / ".config" / "aitbc" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" # Load existing config if config_file.exists(): with open(config_file) as f: config_data = yaml.safe_load(f) or {} else: config_data = {} # Set the value if key == "api_key": config_data["api_key"] = value if ctx.obj['output_format'] == 'table': success("API key set (use --global to set permanently)") elif key == "coordinator_url": config_data["coordinator_url"] = value if ctx.obj['output_format'] == 'table': success(f"Coordinator URL set to: {value}") elif key == "timeout": try: config_data["timeout"] = int(value) if ctx.obj['output_format'] == 'table': success(f"Timeout set to: {value}s") except ValueError: error("Timeout must be an integer") ctx.exit(1) else: error(f"Unknown configuration key: {key}") ctx.exit(1) # Save config with open(config_file, 'w') as f: yaml.dump(config_data, f, default_flow_style=False) output({ "config_file": str(config_file), "key": key, "value": value }, ctx.obj['output_format']) @config.command() @click.argument("key") @click.option("--global", "global_config", is_flag=True, help="Get from global config") @click.pass_context def get(ctx, key: str, global_config: bool): """Get configuration value""" config = ctx.obj['config'] # Determine config file path if global_config: config_dir = Path.home() / ".config" / "aitbc" config_file = config_dir / "config.yaml" else: config_file = getattr(config, 'config_file', None) if not config_file or not Path(config_file).exists(): # Try to get from current config object value = getattr(config, key, None) if value is not None: output({key: value}, ctx.obj['output_format']) else: error(f"Configuration key '{key}' not found") ctx.exit(1) return # Load config from file try: with open(config_file, 'r') as f: config_data = yaml.safe_load(f) or {} if key in config_data: output({key: config_data[key]}, ctx.obj['output_format']) else: error(f"Configuration key '{key}' not found") ctx.exit(1) except Exception as e: error(f"Failed to read config: {e}") ctx.exit(1) @config.command() @click.option("--global", "global_config", is_flag=True, help="Show global config") def path(global_config: bool): """Show configuration file path""" if global_config: config_dir = Path.home() / ".config" / "aitbc" config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" output({ "config_file": str(config_file), "exists": config_file.exists() }) @config.command() @click.option("--global", "global_config", is_flag=True, help="Edit global config") @click.pass_context def edit(ctx, global_config: bool): """Open configuration file in editor""" # Determine config file path if global_config: config_dir = Path.home() / ".config" / "aitbc" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" # Create if doesn't exist if not config_file.exists(): config = ctx.obj['config'] config_data = { "coordinator_url": config.coordinator_url, "timeout": getattr(config, 'timeout', 30) } with open(config_file, 'w') as f: yaml.dump(config_data, f, default_flow_style=False) # Open in editor editor = os.getenv('EDITOR', 'nano').strip() or 'nano' editor_cmd = shlex.split(editor) subprocess.run([*editor_cmd, str(config_file)], check=False) @config.command() @click.option("--global", "global_config", is_flag=True, help="Reset global config") @click.pass_context def reset(ctx, global_config: bool): """Reset configuration to defaults""" # Determine config file path if global_config: config_dir = Path.home() / ".config" / "aitbc" config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" if not config_file.exists(): output({"message": "No configuration file found"}) return if not click.confirm(f"Reset configuration at {config_file}?"): return # Remove config file config_file.unlink() success("Configuration reset to defaults") @config.command() @click.option("--format", "output_format", type=click.Choice(['yaml', 'json']), default='yaml', help="Output format") @click.option("--global", "global_config", is_flag=True, help="Export global config") @click.pass_context def export(ctx, output_format: str, global_config: bool): """Export configuration""" # Determine config file path if global_config: config_dir = Path.home() / ".config" / "aitbc" config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" if not config_file.exists(): error("No configuration file found") ctx.exit(1) with open(config_file) as f: config_data = yaml.safe_load(f) or {} # Redact sensitive data if 'api_key' in config_data: config_data['api_key'] = "***REDACTED***" if output_format == 'json': click.echo(json.dumps(config_data, indent=2)) else: click.echo(yaml.dump(config_data, default_flow_style=False)) @config.command() @click.argument("file_path") @click.option("--merge", is_flag=True, help="Merge with existing config") @click.option("--global", "global_config", is_flag=True, help="Import to global config") @click.pass_context def import_config(ctx, file_path: str, merge: bool, global_config: bool): """Import configuration from file""" import_file = Path(file_path) if not import_file.exists(): error(f"File not found: {file_path}") ctx.exit(1) # Load import file try: with open(import_file) as f: if import_file.suffix.lower() == '.json': import_data = json.load(f) else: import_data = yaml.safe_load(f) except json.JSONDecodeError: error("Invalid JSON data") ctx.exit(1) except Exception as e: error(f"Failed to parse file: {e}") ctx.exit(1) # Determine target config file if global_config: config_dir = Path.home() / ".config" / "aitbc" config_dir.mkdir(parents=True, exist_ok=True) config_file = config_dir / "config.yaml" else: config_file = Path.cwd() / ".aitbc.yaml" # Load existing config if merging if merge and config_file.exists(): with open(config_file) as f: config_data = yaml.safe_load(f) or {} config_data.update(import_data) else: config_data = import_data # Save config with open(config_file, 'w') as f: yaml.dump(config_data, f, default_flow_style=False) if ctx.obj['output_format'] == 'table': success(f"Configuration imported to {config_file}") @config.command() @click.pass_context def validate(ctx): """Validate configuration""" config = ctx.obj['config'] errors = [] warnings = [] # Validate coordinator URL if not config.coordinator_url: errors.append("Coordinator URL is not set") elif not config.coordinator_url.startswith(('http://', 'https://')): errors.append("Coordinator URL must start with http:// or https://") # Validate API key if not config.api_key: warnings.append("API key is not set") elif len(config.api_key) < 10: errors.append("API key appears to be too short") # Validate timeout timeout = getattr(config, 'timeout', 30) if not isinstance(timeout, (int, float)) or timeout <= 0: errors.append("Timeout must be a positive number") # Output results result = { "valid": len(errors) == 0, "errors": errors, "warnings": warnings } if errors: error("Configuration validation failed") ctx.exit(1) elif warnings: if ctx.obj['output_format'] == 'table': success("Configuration valid with warnings") else: if ctx.obj['output_format'] == 'table': success("Configuration is valid") output(result, ctx.obj['output_format']) @config.command() def environments(): """List available environments""" env_vars = [ 'AITBC_COORDINATOR_URL', 'AITBC_API_KEY', 'AITBC_TIMEOUT', 'AITBC_CONFIG_FILE', 'CLIENT_API_KEY', 'MINER_API_KEY', 'ADMIN_API_KEY' ] env_data = {} for var in env_vars: value = os.getenv(var) if value: if 'API_KEY' in var: value = "***REDACTED***" env_data[var] = value output({ "environment_variables": env_data, "note": "Use export VAR=value to set environment variables" }) @config.group() def profiles(): """Manage configuration profiles""" pass @profiles.command() @click.argument("name") @click.pass_context def save(ctx, name: str): """Save current configuration as a profile""" config = ctx.obj['config'] # Create profiles directory profiles_dir = Path.home() / ".config" / "aitbc" / "profiles" profiles_dir.mkdir(parents=True, exist_ok=True) profile_file = profiles_dir / f"{name}.yaml" # Save profile (without API key) profile_data = { "coordinator_url": config.coordinator_url, "timeout": getattr(config, 'timeout', 30) } with open(profile_file, 'w') as f: yaml.dump(profile_data, f, default_flow_style=False) if ctx.obj['output_format'] == 'table': success(f"Profile '{name}' saved") @profiles.command() def list(): """List available profiles""" profiles_dir = Path.home() / ".config" / "aitbc" / "profiles" if not profiles_dir.exists(): output({"profiles": []}) return profiles = [] for profile_file in profiles_dir.glob("*.yaml"): with open(profile_file) as f: profile_data = yaml.safe_load(f) profiles.append({ "name": profile_file.stem, "coordinator_url": profile_data.get("coordinator_url"), "timeout": profile_data.get("timeout", 30) }) output({"profiles": profiles}) @profiles.command() @click.argument("name") @click.pass_context def load(ctx, name: str): """Load a configuration profile""" profiles_dir = Path.home() / ".config" / "aitbc" / "profiles" profile_file = profiles_dir / f"{name}.yaml" if not profile_file.exists(): error(f"Profile '{name}' not found") ctx.exit(1) with open(profile_file) as f: profile_data = yaml.safe_load(f) # Load to current config config_file = Path.cwd() / ".aitbc.yaml" with open(config_file, 'w') as f: yaml.dump(profile_data, f, default_flow_style=False) if ctx.obj['output_format'] == 'table': success(f"Profile '{name}' loaded") @profiles.command() @click.argument("name") @click.pass_context def delete(ctx, name: str): """Delete a configuration profile""" profiles_dir = Path.home() / ".config" / "aitbc" / "profiles" profile_file = profiles_dir / f"{name}.yaml" if not profile_file.exists(): error(f"Profile '{name}' not found") ctx.exit(1) if not click.confirm(f"Delete profile '{name}'?"): return profile_file.unlink() if ctx.obj['output_format'] == 'table': success(f"Profile '{name}' deleted") @config.command(name="set-secret") @click.argument("key") @click.argument("value") @click.pass_context def set_secret(ctx, key: str, value: str): """Set an encrypted configuration value""" from utils import encrypt_value config_dir = Path.home() / ".config" / "aitbc" config_dir.mkdir(parents=True, exist_ok=True) secrets_file = config_dir / "secrets.json" secrets = {} if secrets_file.exists(): with open(secrets_file) as f: secrets = json.load(f) secrets[key] = encrypt_value(value) with open(secrets_file, "w") as f: json.dump(secrets, f, indent=2) # Restrict file permissions secrets_file.chmod(0o600) if ctx.obj['output_format'] == 'table': success(f"Secret '{key}' saved (encrypted)") output({"key": key, "status": "encrypted"}, ctx.obj['output_format']) @config.command(name="get-secret") @click.argument("key") @click.pass_context def get_secret(ctx, key: str): """Get a decrypted configuration value""" from utils import decrypt_value secrets_file = Path.home() / ".config" / "aitbc" / "secrets.json" if not secrets_file.exists(): error("No secrets file found") ctx.exit(1) return with open(secrets_file) as f: secrets = json.load(f) if key not in secrets: error(f"Secret '{key}' not found") ctx.exit(1) return decrypted = decrypt_value(secrets[key]) output({"key": key, "value": decrypted}, ctx.obj['output_format']) # Add profiles group to config config.add_command(profiles)