open-swarm 0.1.1743070217__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. open_swarm-0.1.1743070217.dist-info/METADATA +258 -0
  2. open_swarm-0.1.1743070217.dist-info/RECORD +89 -0
  3. open_swarm-0.1.1743070217.dist-info/WHEEL +5 -0
  4. open_swarm-0.1.1743070217.dist-info/entry_points.txt +3 -0
  5. open_swarm-0.1.1743070217.dist-info/licenses/LICENSE +21 -0
  6. open_swarm-0.1.1743070217.dist-info/top_level.txt +1 -0
  7. swarm/__init__.py +3 -0
  8. swarm/agent/__init__.py +7 -0
  9. swarm/agent/agent.py +49 -0
  10. swarm/apps.py +53 -0
  11. swarm/auth.py +56 -0
  12. swarm/consumers.py +141 -0
  13. swarm/core.py +326 -0
  14. swarm/extensions/__init__.py +1 -0
  15. swarm/extensions/blueprint/__init__.py +36 -0
  16. swarm/extensions/blueprint/agent_utils.py +45 -0
  17. swarm/extensions/blueprint/blueprint_base.py +562 -0
  18. swarm/extensions/blueprint/blueprint_discovery.py +112 -0
  19. swarm/extensions/blueprint/blueprint_utils.py +17 -0
  20. swarm/extensions/blueprint/common_utils.py +12 -0
  21. swarm/extensions/blueprint/django_utils.py +203 -0
  22. swarm/extensions/blueprint/interactive_mode.py +102 -0
  23. swarm/extensions/blueprint/modes/rest_mode.py +37 -0
  24. swarm/extensions/blueprint/output_utils.py +95 -0
  25. swarm/extensions/blueprint/spinner.py +91 -0
  26. swarm/extensions/cli/__init__.py +0 -0
  27. swarm/extensions/cli/blueprint_runner.py +251 -0
  28. swarm/extensions/cli/cli_args.py +88 -0
  29. swarm/extensions/cli/commands/__init__.py +0 -0
  30. swarm/extensions/cli/commands/blueprint_management.py +31 -0
  31. swarm/extensions/cli/commands/config_management.py +15 -0
  32. swarm/extensions/cli/commands/edit_config.py +77 -0
  33. swarm/extensions/cli/commands/list_blueprints.py +22 -0
  34. swarm/extensions/cli/commands/validate_env.py +57 -0
  35. swarm/extensions/cli/commands/validate_envvars.py +39 -0
  36. swarm/extensions/cli/interactive_shell.py +41 -0
  37. swarm/extensions/cli/main.py +36 -0
  38. swarm/extensions/cli/selection.py +43 -0
  39. swarm/extensions/cli/utils/discover_commands.py +32 -0
  40. swarm/extensions/cli/utils/env_setup.py +15 -0
  41. swarm/extensions/cli/utils.py +105 -0
  42. swarm/extensions/config/__init__.py +6 -0
  43. swarm/extensions/config/config_loader.py +208 -0
  44. swarm/extensions/config/config_manager.py +258 -0
  45. swarm/extensions/config/server_config.py +49 -0
  46. swarm/extensions/config/setup_wizard.py +103 -0
  47. swarm/extensions/config/utils/__init__.py +0 -0
  48. swarm/extensions/config/utils/logger.py +36 -0
  49. swarm/extensions/launchers/__init__.py +1 -0
  50. swarm/extensions/launchers/build_launchers.py +14 -0
  51. swarm/extensions/launchers/build_swarm_wrapper.py +12 -0
  52. swarm/extensions/launchers/swarm_api.py +68 -0
  53. swarm/extensions/launchers/swarm_cli.py +304 -0
  54. swarm/extensions/launchers/swarm_wrapper.py +29 -0
  55. swarm/extensions/mcp/__init__.py +1 -0
  56. swarm/extensions/mcp/cache_utils.py +36 -0
  57. swarm/extensions/mcp/mcp_client.py +341 -0
  58. swarm/extensions/mcp/mcp_constants.py +7 -0
  59. swarm/extensions/mcp/mcp_tool_provider.py +110 -0
  60. swarm/llm/chat_completion.py +195 -0
  61. swarm/messages.py +132 -0
  62. swarm/migrations/0010_initial_chat_models.py +51 -0
  63. swarm/migrations/__init__.py +0 -0
  64. swarm/models.py +45 -0
  65. swarm/repl/__init__.py +1 -0
  66. swarm/repl/repl.py +87 -0
  67. swarm/serializers.py +12 -0
  68. swarm/settings.py +189 -0
  69. swarm/tool_executor.py +239 -0
  70. swarm/types.py +126 -0
  71. swarm/urls.py +89 -0
  72. swarm/util.py +124 -0
  73. swarm/utils/color_utils.py +40 -0
  74. swarm/utils/context_utils.py +272 -0
  75. swarm/utils/general_utils.py +162 -0
  76. swarm/utils/logger.py +61 -0
  77. swarm/utils/logger_setup.py +25 -0
  78. swarm/utils/message_sequence.py +173 -0
  79. swarm/utils/message_utils.py +95 -0
  80. swarm/utils/redact.py +68 -0
  81. swarm/views/__init__.py +41 -0
  82. swarm/views/api_views.py +46 -0
  83. swarm/views/chat_views.py +76 -0
  84. swarm/views/core_views.py +118 -0
  85. swarm/views/message_views.py +40 -0
  86. swarm/views/model_views.py +135 -0
  87. swarm/views/utils.py +457 -0
  88. swarm/views/web_views.py +149 -0
  89. swarm/wsgi.py +16 -0
@@ -0,0 +1,43 @@
1
+ from typing import Dict, Optional, Any
2
+ from swarm.utils.color_utils import color_text
3
+ import logging
4
+
5
+ # Configure logger
6
+ logger = logging.getLogger(__name__)
7
+ logger.setLevel(logging.INFO)
8
+
9
+ def prompt_user_to_select_blueprint(blueprints_metadata: Dict[str, Dict[str, Any]]) -> Optional[str]:
10
+ """
11
+ Allow the user to select a blueprint from available options.
12
+
13
+ Args:
14
+ blueprints_metadata (Dict[str, Dict[str, Any]]): Metadata of available blueprints.
15
+
16
+ Returns:
17
+ Optional[str]: Selected blueprint name, or None if no selection is made.
18
+ """
19
+ if not blueprints_metadata:
20
+ logger.warning("No blueprints available. Blueprint selection skipped.")
21
+ print(color_text("No blueprints available. Please add blueprints to continue.", "yellow"))
22
+ return None # Updated to remove 'default blueprint'
23
+
24
+ print("\nAvailable Blueprints:")
25
+ for idx, (key, metadata) in enumerate(blueprints_metadata.items(), start=1):
26
+ print(f"{idx}. {metadata.get('title', key)} - {metadata.get('description', 'No description available')}")
27
+
28
+ while True:
29
+ try:
30
+ choice = int(input("\nEnter the number of the blueprint you want to run (0 to cancel): "))
31
+ if choice == 0:
32
+ logger.info("User chose to cancel blueprint selection.")
33
+ return None # Explicitly return None when selection is canceled
34
+ elif 1 <= choice <= len(blueprints_metadata):
35
+ selected_key = list(blueprints_metadata.keys())[choice - 1]
36
+ logger.info(f"User selected blueprint: '{selected_key}'")
37
+ return selected_key
38
+ else:
39
+ print(f"Please enter a number between 0 and {len(blueprints_metadata)}.")
40
+ logger.warning(f"User entered invalid blueprint number: {choice}")
41
+ except ValueError:
42
+ print("Invalid input. Please enter a valid number.")
43
+ logger.warning("User entered non-integer value for blueprint selection.")
@@ -0,0 +1,32 @@
1
+ """
2
+ Utility to discover and load CLI commands dynamically.
3
+ """
4
+
5
+ import os
6
+ import importlib.util
7
+
8
+ def discover_commands(commands_dir):
9
+ """
10
+ Discover all commands in the given directory.
11
+
12
+ Args:
13
+ commands_dir (str): Path to the commands directory.
14
+
15
+ Returns:
16
+ dict: A dictionary of commands with metadata.
17
+ """
18
+ commands = {}
19
+ for filename in os.listdir(commands_dir):
20
+ if filename.endswith(".py") and filename != "__init__.py":
21
+ module_name = f"swarm.extensions.cli.commands.{filename[:-3]}"
22
+ spec = importlib.util.find_spec(module_name)
23
+ if not spec:
24
+ continue
25
+ module = importlib.util.module_from_spec(spec)
26
+ spec.loader.exec_module(module)
27
+ commands[module_name] = {
28
+ "description": getattr(module, "description", "No description provided."),
29
+ "usage": getattr(module, "usage", "No usage available."),
30
+ "execute": getattr(module, "execute", None),
31
+ }
32
+ return commands
@@ -0,0 +1,15 @@
1
+ # Handles .env management and environment validation for the CLI
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+ def validate_env():
7
+ """Ensure all required environment variables are set."""
8
+ load_dotenv()
9
+ required_vars = ["API_KEY", "MCP_SERVER"]
10
+ missing = [var for var in required_vars if not os.getenv(var)]
11
+ if missing:
12
+ print(f"Missing required environment variables: {', '.join(missing)}")
13
+ return False
14
+ print("Environment validation passed.")
15
+ return True
@@ -0,0 +1,105 @@
1
+ import logging
2
+ import sys
3
+ from typing import Dict, Optional
4
+ from swarm.utils.color_utils import color_text
5
+
6
+ import os
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def find_project_root(current_path: str, marker: str = ".git") -> str:
11
+ """
12
+ Recursively search for the project root by looking for a specific marker file or directory.
13
+
14
+ Args:
15
+ current_path (str): Starting path for the search.
16
+ marker (str): Marker file or directory to identify the project root.
17
+
18
+ Returns:
19
+ str: Path to the project root.
20
+
21
+ Raises:
22
+ FileNotFoundError: If the project root cannot be found.
23
+ """
24
+ while True:
25
+ if os.path.exists(os.path.join(current_path, marker)):
26
+ return current_path
27
+ new_path = os.path.dirname(current_path)
28
+ if new_path == current_path:
29
+ break
30
+ current_path = new_path
31
+ logger.error(f"Project root with marker '{marker}' not found.")
32
+ raise FileNotFoundError(f"Project root with marker '{marker}' not found.")
33
+
34
+ def display_message(message: str, message_type: str = "info") -> None:
35
+ """
36
+ Display a message to the user with optional color formatting.
37
+
38
+ Args:
39
+ message (str): The message to display.
40
+ message_type (str): The type of message (info, warning, error).
41
+ """
42
+ color_map = {
43
+ "info": "cyan",
44
+ "warning": "yellow",
45
+ "error": "red"
46
+ }
47
+ color = color_map.get(message_type, "cyan")
48
+ print(color_text(message, color))
49
+ if message_type == "error":
50
+ logger.error(message)
51
+ elif message_type == "warning":
52
+ logger.warning(message)
53
+ else:
54
+ logger.info(message)
55
+
56
+ def prompt_user(prompt: str, default: Optional[str] = None) -> str:
57
+ """
58
+ Prompt the user for input with an optional default value.
59
+
60
+ Args:
61
+ prompt (str): The prompt to display to the user.
62
+ default (Optional[str]): The default value to use if the user provides no input.
63
+
64
+ Returns:
65
+ str: The user's input or the default value.
66
+ """
67
+ if default:
68
+ prompt = f"{prompt} [{default}]: "
69
+ else:
70
+ prompt = f"{prompt}: "
71
+ user_input = input(prompt).strip()
72
+ return user_input or default
73
+
74
+ def validate_input(user_input: str, valid_options: list, default: Optional[str] = None) -> str:
75
+ """
76
+ Validate the user's input against a list of valid options.
77
+
78
+ Args:
79
+ user_input (str): The user's input.
80
+ valid_options (list): A list of valid options.
81
+ default (Optional[str]): A default value to use if the input is invalid.
82
+
83
+ Returns:
84
+ str: The valid input or the default value.
85
+ """
86
+ if user_input in valid_options:
87
+ return user_input
88
+ elif default is not None:
89
+ display_message(f"Invalid input. Using default: {default}", "warning")
90
+ return default
91
+ else:
92
+ display_message(f"Invalid input. Valid options are: {', '.join(valid_options)}", "error")
93
+ raise ValueError(f"Invalid input: {user_input}")
94
+
95
+ def log_and_exit(message: str, code: int = 1) -> None:
96
+ """
97
+ Log an error message and exit the application.
98
+
99
+ Args:
100
+ message (str): The error message to log.
101
+ code (int): The exit code to use.
102
+ """
103
+ logger.error(message)
104
+ display_message(message, "error")
105
+ sys.exit(code)
@@ -0,0 +1,6 @@
1
+ # src/swarm/config/__init__.py
2
+
3
+ """
4
+ Configuration module for Open Swarm MCP.
5
+ Handles loading, validating, and setting up configurations.
6
+ """
@@ -0,0 +1,208 @@
1
+ """
2
+ Configuration Loader for Open Swarm MCP Framework.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import re
8
+ import logging
9
+ from typing import Any, Dict, List, Tuple, Optional
10
+ from pathlib import Path
11
+ from dotenv import load_dotenv
12
+ try: from .server_config import save_server_config
13
+ except ImportError: save_server_config = None
14
+
15
+ SWARM_DEBUG = os.getenv("SWARM_DEBUG", "False").lower() in ("true", "1", "yes")
16
+ try: from swarm.settings import BASE_DIR
17
+ except ImportError: BASE_DIR = Path(__file__).resolve().parent.parent.parent
18
+
19
+ from swarm.utils.redact import redact_sensitive_data
20
+
21
+ logger = logging.getLogger(__name__)
22
+ logger.setLevel(logging.DEBUG if SWARM_DEBUG else logging.INFO)
23
+
24
+ # Add handler only if needed, DO NOT set handler level conditionally here
25
+ if not logger.handlers and not logging.getLogger().hasHandlers():
26
+ stream_handler = logging.StreamHandler()
27
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s:%(lineno)d - %(message)s")
28
+ stream_handler.setFormatter(formatter)
29
+ logger.addHandler(stream_handler)
30
+ # --- REMOVED CONDITIONAL HANDLER LEVEL SETTING ---
31
+ # if not SWARM_DEBUG:
32
+ # stream_handler.setLevel(logging.WARNING)
33
+
34
+ config: Dict[str, Any] = {}
35
+ load_dotenv()
36
+ logger.debug("Environment variables potentially loaded from .env file.")
37
+
38
+ def process_config(config_dict: dict) -> dict:
39
+ """Processes config: resolves placeholders, merges external MCP."""
40
+ try:
41
+ resolved_config = resolve_placeholders(config_dict)
42
+ if logger.isEnabledFor(logging.DEBUG): logger.debug("Config after resolving placeholders: " + json.dumps(redact_sensitive_data(resolved_config), indent=2))
43
+ disable_merge = os.getenv("DISABLE_MCP_MERGE", "false").lower() in ("true", "1", "yes")
44
+ if not disable_merge:
45
+ if os.name == "nt": external_mcp_path = Path(os.getenv("APPDATA", Path.home())) / "Claude" / "claude_desktop_config.json"
46
+ else: external_mcp_path = Path.home() / ".vscode-server" / "data" / "User" / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
47
+ if external_mcp_path.exists():
48
+ logger.info(f"Found external MCP settings file at: {external_mcp_path}")
49
+ try:
50
+ with open(external_mcp_path, "r", encoding='utf-8') as mcp_file: external_mcp_config = json.load(mcp_file)
51
+ if logger.isEnabledFor(logging.DEBUG): logger.debug("Loaded external MCP settings: " + json.dumps(redact_sensitive_data(external_mcp_config), indent=2))
52
+ main_mcp_servers = resolved_config.get("mcpServers", {}); external_mcp_servers = external_mcp_config.get("mcpServers", {})
53
+ merged_mcp_servers = main_mcp_servers.copy(); servers_added_count = 0
54
+ for name, server_cfg in external_mcp_servers.items():
55
+ if name not in merged_mcp_servers and not server_cfg.get("disabled", False): merged_mcp_servers[name] = server_cfg; servers_added_count += 1
56
+ if servers_added_count > 0: resolved_config["mcpServers"] = merged_mcp_servers; logger.info(f"Merged {servers_added_count} MCP servers.");
57
+ else: logger.debug("No new MCP servers added from external settings.")
58
+ except Exception as merge_err: logger.error(f"Failed to load/merge MCP settings from '{external_mcp_path}': {merge_err}", exc_info=logger.isEnabledFor(logging.DEBUG))
59
+ else: logger.debug(f"External MCP settings file not found at {external_mcp_path}. Skipping merge.")
60
+ else: logger.debug("MCP settings merge disabled.")
61
+ except Exception as e: logger.error(f"Failed during config processing: {e}", exc_info=logger.isEnabledFor(logging.DEBUG)); raise
62
+ globals()["config"] = resolved_config
63
+ return resolved_config
64
+
65
+ def resolve_placeholders(obj: Any) -> Any:
66
+ """Recursively resolve ${VAR_NAME} placeholders."""
67
+ if isinstance(obj, dict): return {k: resolve_placeholders(v) for k, v in obj.items()}
68
+ elif isinstance(obj, list): return [resolve_placeholders(item) for item in obj]
69
+ elif isinstance(obj, str):
70
+ pattern = re.compile(r'\$\{(\w+(?:[_-]\w+)*)\}')
71
+ resolved_string = obj; any_unresolved = False
72
+ for var_name in pattern.findall(obj):
73
+ env_value = os.getenv(var_name); placeholder = f'${{{var_name}}}'
74
+ if env_value is None:
75
+ log_level = logging.DEBUG
76
+ if resolved_string == placeholder:
77
+ log_level = logging.WARNING
78
+ resolved_string = None
79
+ any_unresolved = True
80
+ logger.log(log_level, f"Env var '{var_name}' not set for placeholder '{placeholder}'. Resolving to None.")
81
+ return None
82
+ else:
83
+ resolved_string = resolved_string.replace(placeholder, "")
84
+ any_unresolved = True
85
+ logger.log(log_level, f"Env var '{var_name}' not set for placeholder '{placeholder}'. Removing from string.")
86
+ else:
87
+ resolved_string = resolved_string.replace(placeholder, env_value)
88
+ if logger.isEnabledFor(logging.DEBUG): logger.debug(f"Resolved placeholder '{placeholder}' using env var '{var_name}'.")
89
+ if any_unresolved and resolved_string is not None:
90
+ logger.debug(f"String '{obj}' contained unresolved placeholders. Result: '{resolved_string}'")
91
+ return resolved_string
92
+ else: return obj
93
+
94
+ def load_server_config(file_path: Optional[str] = None) -> dict:
95
+ """Loads, resolves, and merges server config from JSON file."""
96
+ config_path: Optional[Path] = None
97
+ if file_path:
98
+ path_obj = Path(file_path)
99
+ if path_obj.is_file(): config_path = path_obj; logger.info(f"Using provided config file path: {config_path}")
100
+ else: logger.warning(f"Provided path '{file_path}' not found. Searching standard locations.")
101
+ if not config_path:
102
+ standard_paths = [ Path.cwd() / "swarm_config.json", Path(BASE_DIR) / "swarm_config.json", Path.home() / ".swarm" / "swarm_config.json" ]
103
+ config_path = next((p for p in standard_paths if p.is_file()), None)
104
+ if not config_path: raise FileNotFoundError(f"Config file 'swarm_config.json' not found. Checked: {[str(p) for p in standard_paths]}")
105
+ logger.info(f"Using config file found at: {config_path}")
106
+ try:
107
+ raw_config = json.loads(config_path.read_text(encoding='utf-8'))
108
+ if logger.isEnabledFor(logging.DEBUG): logger.debug(f"Raw config loaded: {redact_sensitive_data(raw_config)}")
109
+ processed_config = process_config(raw_config)
110
+ globals()["config"] = processed_config
111
+ logger.info(f"Config loaded and processed from {config_path}")
112
+ return processed_config
113
+ except json.JSONDecodeError as e: logger.critical(f"Invalid JSON in {config_path}: {e}"); raise ValueError(f"Invalid JSON") from e
114
+ except Exception as e: logger.critical(f"Failed to read/process config {config_path}: {e}"); raise ValueError("Failed to load/process config") from e
115
+
116
+ def load_llm_config(config_dict: Optional[Dict[str, Any]] = None, llm_name: Optional[str] = None) -> Dict[str, Any]:
117
+ """Loads, validates, and resolves API keys for a specific LLM profile."""
118
+ if config_dict is None:
119
+ global_config = globals().get("config")
120
+ if not global_config:
121
+ try: config_dict = load_server_config(); globals()["config"] = config_dict
122
+ except Exception as e: raise ValueError("Global config not loaded and no config_dict provided.") from e
123
+ else: config_dict = global_config
124
+
125
+ target_llm_name = llm_name or os.getenv("DEFAULT_LLM", "default")
126
+ logger.debug(f"LOAD_LLM: Loading profile: '{target_llm_name}'.")
127
+
128
+ resolved_config = resolve_placeholders(config_dict)
129
+ llm_profiles = resolved_config.get("llm", {})
130
+ if not isinstance(llm_profiles, dict): raise ValueError("'llm' section must be a dictionary.")
131
+
132
+ llm_config = llm_profiles.get(target_llm_name)
133
+ config_source = f"config file ('{target_llm_name}')"
134
+ logger.debug(f"LOAD_LLM: Initial lookup for '{target_llm_name}': {'Found' if llm_config else 'Missing'}")
135
+
136
+ if not llm_config:
137
+ logger.warning(f"LOAD_LLM: Config for '{target_llm_name}' not found. Generating fallback.")
138
+ config_source = "fallback generation"
139
+ fb_provider = os.getenv("DEFAULT_LLM_PROVIDER", "openai"); fb_model = os.getenv("DEFAULT_LLM_MODEL", "gpt-4o")
140
+ llm_config = { "provider": fb_provider, "model": fb_model, "api_key": None, "base_url": None }
141
+ logger.debug(f"LOAD_LLM: Generated fallback core config: {llm_config}")
142
+
143
+ if not isinstance(llm_config, dict): raise ValueError(f"LLM profile '{target_llm_name}' must be a dictionary.")
144
+
145
+ final_api_key = llm_config.get("api_key"); key_log_source = f"{config_source} (resolved)" if final_api_key else config_source
146
+ provider = llm_config.get("provider"); api_key_required = llm_config.get("api_key_required", True)
147
+ logger.debug(f"LOAD_LLM: Initial key from {key_log_source}: {'****' if final_api_key else 'None'}. Required={api_key_required}")
148
+
149
+ logger.debug(f"LOAD_LLM: Checking ENV vars for potential override.")
150
+ specific_env_var_name = f"{provider.upper()}_API_KEY" if provider else "PROVIDER_API_KEY"
151
+ common_fallback_var = "OPENAI_API_KEY"
152
+ specific_key_from_env = os.getenv(specific_env_var_name); fallback_key_from_env = os.getenv(common_fallback_var)
153
+ logger.debug(f"LOAD_LLM: Env Check: Specific ('{specific_env_var_name}')={'****' if specific_key_from_env else 'None'}, Fallback ('{common_fallback_var}')={'****' if fallback_key_from_env else 'None'}")
154
+
155
+ if specific_key_from_env:
156
+ if final_api_key != specific_key_from_env: logger.info(f"LOAD_LLM: Overriding key with env var '{specific_env_var_name}'.")
157
+ final_api_key = specific_key_from_env; key_log_source = f"env var '{specific_env_var_name}'"
158
+ elif fallback_key_from_env:
159
+ if not specific_key_from_env or specific_env_var_name == common_fallback_var:
160
+ if final_api_key != fallback_key_from_env: logger.info(f"LOAD_LLM: Overriding key with fallback env var '{common_fallback_var}'.")
161
+ final_api_key = fallback_key_from_env; key_log_source = f"env var '{common_fallback_var}'"
162
+ else: logger.debug(f"LOAD_LLM: Specific env key '{specific_env_var_name}' unset, NOT using fallback.")
163
+ else: logger.debug(f"LOAD_LLM: No relevant API key found in environment variables.")
164
+
165
+ key_is_still_missing_or_empty = final_api_key is None or (isinstance(final_api_key, str) and not final_api_key.strip())
166
+ logger.debug(f"LOAD_LLM: Key after env check: {'****' if final_api_key else 'None'}. Source: {key_log_source}. Still MissingOrEmpty={key_is_still_missing_or_empty}")
167
+
168
+ if key_is_still_missing_or_empty:
169
+ if api_key_required and not os.getenv("SUPPRESS_DUMMY_KEY"):
170
+ final_api_key = "sk-DUMMYKEY"; key_log_source = "dummy key"; logger.warning(f"LOAD_LLM: Applying dummy key for '{target_llm_name}'.")
171
+ elif api_key_required:
172
+ key_log_source = "MISSING - ERROR"; raise ValueError(f"Required API key for LLM profile '{target_llm_name}' is missing.")
173
+ else: key_log_source = "Not Required/Not Found"
174
+
175
+ final_llm_config = llm_config.copy(); final_llm_config["api_key"] = final_api_key; final_llm_config["_log_key_source"] = key_log_source
176
+ logger.debug(f"LOAD_LLM: Returning final config for '{target_llm_name}': {redact_sensitive_data(final_llm_config)}")
177
+ return final_llm_config
178
+
179
+ def get_llm_model(config_dict: Dict[str, Any], llm_name: Optional[str] = None) -> str:
180
+ target_llm_name = llm_name or os.getenv("DEFAULT_LLM", "default")
181
+ try: llm_config = load_llm_config(config_dict, target_llm_name)
182
+ except ValueError as e: raise ValueError(f"Could not load config for LLM '{target_llm_name}': {e}") from e
183
+ model_name = llm_config.get("model")
184
+ if not model_name or not isinstance(model_name, str): raise ValueError(f"'model' name missing/invalid for LLM '{target_llm_name}'.")
185
+ logger.debug(f"Retrieved model name '{model_name}' for LLM '{target_llm_name}'")
186
+ return model_name
187
+
188
+ def load_and_validate_llm(config_dict: Dict[str, Any], llm_name: Optional[str] = None) -> Dict[str, Any]:
189
+ target_llm_name = llm_name or os.getenv("DEFAULT_LLM", "default")
190
+ logger.debug(f"Loading and validating LLM (via load_llm_config) for profile: {target_llm_name}")
191
+ return load_llm_config(config_dict, target_llm_name)
192
+
193
+ def get_server_params(server_config: Dict[str, Any], server_name: str) -> Optional[Dict[str, Any]]:
194
+ """Extracts and validates parameters needed to start an MCP server."""
195
+ command = server_config.get("command"); args = server_config.get("args", []); config_env = server_config.get("env", {})
196
+ if not command: logger.error(f"MCP server '{server_name}' missing 'command'."); return None
197
+ if not isinstance(args, list): logger.error(f"MCP server '{server_name}' 'args' must be list."); return None
198
+ if not isinstance(config_env, dict): logger.error(f"MCP server '{server_name}' 'env' must be dict."); return None
199
+ env = {**os.environ.copy(), **config_env}; valid_env = {}
200
+ for k, v in env.items():
201
+ if v is None: logger.warning(f"Env var '{k}' for MCP server '{server_name}' resolved to None. Omitting.")
202
+ else: valid_env[k] = str(v)
203
+ return {"command": command, "args": args, "env": valid_env}
204
+
205
+ def list_mcp_servers(config_dict: Dict[str, Any]) -> List[str]:
206
+ """Returns a list of configured MCP server names."""
207
+ return list(config_dict.get("mcpServers", {}).keys())
208
+
@@ -0,0 +1,258 @@
1
+ # src/swarm/extensions/config/config_manager.py
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ import logging
7
+ from typing import Any, Dict
8
+
9
+ from swarm.extensions.config.config_loader import (
10
+ load_server_config,
11
+ resolve_placeholders
12
+ )
13
+ from swarm.utils.color_utils import color_text
14
+ from swarm.settings import DEBUG
15
+ from swarm.extensions.cli.utils import (
16
+ prompt_user,
17
+ log_and_exit,
18
+ display_message
19
+ )
20
+
21
+ # Initialize logger for this module
22
+ logger = logging.getLogger(__name__)
23
+ logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
24
+ if not logger.handlers:
25
+ stream_handler = logging.StreamHandler()
26
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s - %(message)s")
27
+ stream_handler.setFormatter(formatter)
28
+ logger.addHandler(stream_handler)
29
+
30
+ CONFIG_BACKUP_SUFFIX = ".backup"
31
+
32
+ def backup_configuration(config_path: str) -> None:
33
+ """
34
+ Create a backup of the existing configuration file.
35
+
36
+ Args:
37
+ config_path (str): Path to the configuration file.
38
+ """
39
+ backup_path = config_path + CONFIG_BACKUP_SUFFIX
40
+ try:
41
+ shutil.copy(config_path, backup_path)
42
+ logger.info(f"Configuration backup created at '{backup_path}'")
43
+ display_message(f"Backup of configuration created at '{backup_path}'", "info")
44
+ except Exception as e:
45
+ logger.error(f"Failed to create configuration backup: {e}")
46
+ display_message(f"Failed to create backup: {e}", "error")
47
+ sys.exit(1)
48
+
49
+ def load_config(config_path: str) -> Dict[str, Any]:
50
+ """
51
+ Load the server configuration from a JSON file and resolve placeholders.
52
+
53
+ Args:
54
+ config_path (str): Path to the configuration file.
55
+
56
+ Returns:
57
+ Dict[str, Any]: The resolved configuration.
58
+
59
+ Raises:
60
+ FileNotFoundError: If the configuration file does not exist.
61
+ ValueError: If the file contains invalid JSON or unresolved placeholders.
62
+ """
63
+ try:
64
+ with open(config_path, "r") as file:
65
+ config = json.load(file)
66
+ logger.debug(f"Raw configuration loaded: {config}")
67
+ except FileNotFoundError:
68
+ logger.error(f"Configuration file not found at {config_path}")
69
+ display_message(f"Configuration file not found at {config_path}", "error")
70
+ sys.exit(1)
71
+ except json.JSONDecodeError as e:
72
+ logger.error(f"Invalid JSON in configuration file {config_path}: {e}")
73
+ display_message(f"Invalid JSON in configuration file {config_path}: {e}", "error")
74
+ sys.exit(1)
75
+
76
+ # Resolve placeholders recursively
77
+ try:
78
+ resolved_config = resolve_placeholders(config)
79
+ logger.debug(f"Configuration after resolving placeholders: {resolved_config}")
80
+ except Exception as e:
81
+ logger.error(f"Failed to resolve placeholders in configuration: {e}")
82
+ display_message(f"Failed to resolve placeholders in configuration: {e}", "error")
83
+ sys.exit(1)
84
+
85
+ return resolved_config
86
+
87
+ def save_config(config_path: str, config: Dict[str, Any]) -> None:
88
+ """
89
+ Save the updated configuration to the config file.
90
+
91
+ Args:
92
+ config_path (str): Path to the configuration file.
93
+ config (Dict[str, Any]): Configuration dictionary to save.
94
+
95
+ Raises:
96
+ SystemExit: If saving the configuration fails.
97
+ """
98
+ try:
99
+ with open(config_path, "w") as f:
100
+ json.dump(config, f, indent=4)
101
+ logger.info(f"Configuration saved to '{config_path}'")
102
+ display_message(f"Configuration saved to '{config_path}'", "info")
103
+ except Exception as e:
104
+ logger.error(f"Failed to save configuration: {e}")
105
+ display_message(f"Failed to save configuration: {e}", "error")
106
+ sys.exit(1)
107
+
108
+ def add_llm(config_path: str) -> None:
109
+ """
110
+ Add a new LLM to the configuration.
111
+
112
+ Args:
113
+ config_path (str): Path to the configuration file.
114
+ """
115
+ config = load_config(config_path)
116
+ display_message("Starting the process to add a new LLM.", "info")
117
+
118
+ while True:
119
+ llm_name = prompt_user("Enter the name of the new LLM (or type 'done' to finish)").strip()
120
+ display_message(f"User entered LLM name: {llm_name}", "info")
121
+ if llm_name.lower() == 'done':
122
+ display_message("Finished adding LLMs.", "info")
123
+ break
124
+ if not llm_name:
125
+ display_message("LLM name cannot be empty.", "error")
126
+ continue
127
+
128
+ if llm_name in config.get("llm", {}):
129
+ display_message(f"LLM '{llm_name}' already exists.", "warning")
130
+ continue
131
+
132
+ llm = {}
133
+ llm["provider"] = prompt_user("Enter the provider type (e.g., 'openai', 'ollama')").strip()
134
+ llm["model"] = prompt_user("Enter the model name (e.g., 'gpt-4')").strip()
135
+ llm["base_url"] = prompt_user("Enter the base URL for the API").strip()
136
+ llm_api_key_input = prompt_user("Enter the environment variable for the API key (e.g., 'OPENAI_API_KEY') or leave empty if not required").strip()
137
+ if llm_api_key_input:
138
+ llm["api_key"] = f"${{{llm_api_key_input}}}"
139
+ else:
140
+ llm["api_key"] = ""
141
+ try:
142
+ temperature_input = prompt_user("Enter the temperature (e.g., 0.7)").strip()
143
+ llm["temperature"] = float(temperature_input)
144
+ except ValueError:
145
+ display_message("Invalid temperature value. Using default 0.7.", "warning")
146
+ llm["temperature"] = 0.7
147
+
148
+ config.setdefault("llm", {})[llm_name] = llm
149
+ logger.info(f"Added LLM '{llm_name}' to configuration.")
150
+ display_message(f"LLM '{llm_name}' added.", "info")
151
+
152
+ backup_configuration(config_path)
153
+ save_config(config_path, config)
154
+ display_message("LLM configuration process completed.", "info")
155
+
156
+ def remove_llm(config_path: str, llm_name: str) -> None:
157
+ """
158
+ Remove an existing LLM from the configuration.
159
+
160
+ Args:
161
+ config_path (str): Path to the configuration file.
162
+ llm_name (str): Name of the LLM to remove.
163
+ """
164
+ config = load_config(config_path)
165
+
166
+ if llm_name not in config.get("llm", {}):
167
+ display_message(f"LLM '{llm_name}' does not exist.", "error")
168
+ return
169
+
170
+ confirm = prompt_user(f"Are you sure you want to remove LLM '{llm_name}'? (yes/no)").strip().lower()
171
+ if confirm not in ['yes', 'y']:
172
+ display_message("Operation cancelled.", "warning")
173
+ return
174
+
175
+ del config["llm"][llm_name]
176
+ backup_configuration(config_path)
177
+ save_config(config_path, config)
178
+ display_message(f"LLM '{llm_name}' has been removed.", "info")
179
+ logger.info(f"Removed LLM '{llm_name}' from configuration.")
180
+
181
+ def add_mcp_server(config_path: str) -> None:
182
+ """
183
+ Add a new MCP server to the configuration.
184
+
185
+ Args:
186
+ config_path (str): Path to the configuration file.
187
+ """
188
+ config = load_config(config_path)
189
+ display_message("Starting the process to add a new MCP server.", "info")
190
+
191
+ while True:
192
+ server_name = prompt_user("Enter the name of the new MCP server (or type 'done' to finish)").strip()
193
+ display_message(f"User entered MCP server name: {server_name}", "info")
194
+ if server_name.lower() == 'done':
195
+ display_message("Finished adding MCP servers.", "info")
196
+ break
197
+ if not server_name:
198
+ display_message("Server name cannot be empty.", "error")
199
+ continue
200
+
201
+ if server_name in config.get("mcpServers", {}):
202
+ display_message(f"MCP server '{server_name}' already exists.", "warning")
203
+ continue
204
+
205
+ server = {}
206
+ server["command"] = prompt_user("Enter the command to run the MCP server (e.g., 'npx', 'uvx')").strip()
207
+ args_input = prompt_user("Enter the arguments as a JSON array (e.g., [\"-y\", \"server-name\"])").strip()
208
+ try:
209
+ server["args"] = json.loads(args_input)
210
+ if not isinstance(server["args"], list):
211
+ raise ValueError
212
+ except ValueError:
213
+ display_message("Invalid arguments format. Using an empty list.", "warning")
214
+ server["args"] = []
215
+
216
+ env_vars = {}
217
+ add_env = prompt_user("Do you want to add environment variables? (yes/no)").strip().lower()
218
+ while add_env in ['yes', 'y']:
219
+ env_var = prompt_user("Enter the environment variable name").strip()
220
+ env_value = prompt_user(f"Enter the value or placeholder for '{env_var}' (e.g., '${{{env_var}_KEY}}')").strip()
221
+ if env_var and env_value:
222
+ env_vars[env_var] = env_value
223
+ add_env = prompt_user("Add another environment variable? (yes/no)").strip().lower()
224
+
225
+ server["env"] = env_vars
226
+
227
+ config.setdefault("mcpServers", {})[server_name] = server
228
+ logger.info(f"Added MCP server '{server_name}' to configuration.")
229
+ display_message(f"MCP server '{server_name}' added.", "info")
230
+
231
+ backup_configuration(config_path)
232
+ save_config(config_path, config)
233
+ display_message("MCP server configuration process completed.", "info")
234
+
235
+ def remove_mcp_server(config_path: str, server_name: str) -> None:
236
+ """
237
+ Remove an existing MCP server from the configuration.
238
+
239
+ Args:
240
+ config_path (str): Path to the configuration file.
241
+ server_name (str): Name of the MCP server to remove.
242
+ """
243
+ config = load_config(config_path)
244
+
245
+ if server_name not in config.get("mcpServers", {}):
246
+ display_message(f"MCP server '{server_name}' does not exist.", "error")
247
+ return
248
+
249
+ confirm = prompt_user(f"Are you sure you want to remove MCP server '{server_name}'? (yes/no)").strip().lower()
250
+ if confirm not in ['yes', 'y']:
251
+ display_message("Operation cancelled.", "warning")
252
+ return
253
+
254
+ del config["mcpServers"][server_name]
255
+ backup_configuration(config_path)
256
+ save_config(config_path, config)
257
+ display_message(f"MCP server '{server_name}' has been removed.", "info")
258
+ logger.info(f"Removed MCP server '{server_name}' from configuration.")