ai-config-cli 0.1.0__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.
ai_config/config.py ADDED
@@ -0,0 +1,260 @@
1
+ """Configuration loading and validation for ai-config."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from ai_config.types import (
9
+ AIConfig,
10
+ ClaudeTargetConfig,
11
+ MarketplaceConfig,
12
+ PluginConfig,
13
+ PluginSource,
14
+ TargetConfig,
15
+ )
16
+
17
+
18
+ class ConfigError(Exception):
19
+ """Base exception for configuration errors."""
20
+
21
+
22
+ class ConfigNotFoundError(ConfigError):
23
+ """Raised when config file is not found."""
24
+
25
+
26
+ class ConfigParseError(ConfigError):
27
+ """Raised when YAML parsing fails."""
28
+
29
+
30
+ class ConfigValidationError(ConfigError):
31
+ """Raised when config schema validation fails."""
32
+
33
+
34
+ DEFAULT_CONFIG_PATHS = [
35
+ Path(".ai-config/config.yaml"),
36
+ Path(".ai-config/config.yml"),
37
+ Path.home() / ".ai-config" / "config.yaml",
38
+ Path.home() / ".ai-config" / "config.yml",
39
+ ]
40
+
41
+
42
+ def find_config_file(config_path: Path | None = None) -> Path:
43
+ """Find the config file to use.
44
+
45
+ Args:
46
+ config_path: Explicit path to config file. If None, searches default locations.
47
+
48
+ Returns:
49
+ Path to the config file.
50
+
51
+ Raises:
52
+ ConfigNotFoundError: If no config file is found.
53
+ """
54
+ if config_path is not None:
55
+ if config_path.exists():
56
+ return config_path
57
+ raise ConfigNotFoundError(f"Config file not found: {config_path}")
58
+
59
+ for path in DEFAULT_CONFIG_PATHS:
60
+ if path.exists():
61
+ return path
62
+
63
+ locations = "\n ".join(str(p) for p in DEFAULT_CONFIG_PATHS)
64
+ raise ConfigNotFoundError(f"No config file found. Searched:\n {locations}")
65
+
66
+
67
+ def _parse_marketplace(
68
+ name: str, data: dict[str, Any], base_dir: Path | None = None
69
+ ) -> MarketplaceConfig:
70
+ """Parse a single marketplace config from raw dict.
71
+
72
+ Args:
73
+ name: Marketplace name.
74
+ data: Raw dict from YAML.
75
+ base_dir: Base directory for resolving relative paths.
76
+ """
77
+ if not isinstance(data, dict):
78
+ raise ConfigValidationError(f"Marketplace '{name}' must be a dict, got: {type(data)}")
79
+
80
+ source_str = data.get("source")
81
+ try:
82
+ source = PluginSource(source_str)
83
+ except ValueError as e:
84
+ valid_sources = [s.value for s in PluginSource]
85
+ raise ConfigValidationError(
86
+ f"Marketplace '{name}' source must be one of {valid_sources}, got: {source_str}"
87
+ ) from e
88
+
89
+ if source == PluginSource.GITHUB:
90
+ repo = data.get("repo")
91
+ if not repo:
92
+ raise ConfigValidationError(
93
+ f"Marketplace '{name}' must have 'repo' field for github source"
94
+ )
95
+ return MarketplaceConfig(source=source, repo=repo)
96
+ else: # local
97
+ path_str = data.get("path")
98
+ if not path_str:
99
+ raise ConfigValidationError(
100
+ f"Marketplace '{name}' must have 'path' field for local source"
101
+ )
102
+
103
+ # Resolve relative paths against base_dir
104
+ path = Path(path_str)
105
+ if not path.is_absolute() and base_dir is not None:
106
+ path = (base_dir / path).resolve()
107
+ else:
108
+ path = path.resolve()
109
+
110
+ return MarketplaceConfig(source=source, path=str(path))
111
+
112
+
113
+ def _parse_plugin(data: dict[str, Any], index: int) -> PluginConfig:
114
+ """Parse a single plugin config from raw dict."""
115
+ if not isinstance(data, dict):
116
+ raise ConfigValidationError(f"Plugin at index {index} must be a dict, got: {type(data)}")
117
+
118
+ plugin_id = data.get("id")
119
+ if not plugin_id:
120
+ raise ConfigValidationError(f"Plugin at index {index} must have 'id' field")
121
+
122
+ scope = data.get("scope", "user")
123
+ if scope not in ("user", "project", "local"):
124
+ raise ConfigValidationError(
125
+ f"Plugin '{plugin_id}' scope must be 'user', 'project', or 'local', got: {scope}"
126
+ )
127
+
128
+ enabled = data.get("enabled", True)
129
+ if not isinstance(enabled, bool):
130
+ raise ConfigValidationError(
131
+ f"Plugin '{plugin_id}' enabled must be boolean, got: {type(enabled)}"
132
+ )
133
+
134
+ return PluginConfig(id=plugin_id, scope=scope, enabled=enabled)
135
+
136
+
137
+ def _parse_claude_config(data: dict[str, Any], base_dir: Path | None = None) -> ClaudeTargetConfig:
138
+ """Parse Claude-specific target config.
139
+
140
+ Args:
141
+ data: Raw dict from YAML.
142
+ base_dir: Base directory for resolving relative paths.
143
+ """
144
+ if not isinstance(data, dict):
145
+ raise ConfigValidationError(f"Claude config must be a dict, got: {type(data)}")
146
+
147
+ marketplaces: dict[str, MarketplaceConfig] = {}
148
+ raw_marketplaces = data.get("marketplaces", {})
149
+ if raw_marketplaces:
150
+ if not isinstance(raw_marketplaces, dict):
151
+ raise ConfigValidationError(
152
+ f"Marketplaces must be a dict, got: {type(raw_marketplaces)}"
153
+ )
154
+ for name, marketplace_data in raw_marketplaces.items():
155
+ marketplaces[name] = _parse_marketplace(name, marketplace_data, base_dir)
156
+
157
+ plugins: list[PluginConfig] = []
158
+ raw_plugins = data.get("plugins", [])
159
+ if raw_plugins:
160
+ if not isinstance(raw_plugins, list):
161
+ raise ConfigValidationError(f"Plugins must be a list, got: {type(raw_plugins)}")
162
+ for i, plugin_data in enumerate(raw_plugins):
163
+ plugins.append(_parse_plugin(plugin_data, i))
164
+
165
+ return ClaudeTargetConfig(marketplaces=marketplaces, plugins=tuple(plugins))
166
+
167
+
168
+ def _parse_target(data: dict[str, Any], index: int, base_dir: Path | None = None) -> TargetConfig:
169
+ """Parse a single target config from raw dict.
170
+
171
+ Args:
172
+ data: Raw dict from YAML.
173
+ index: Index in targets list for error messages.
174
+ base_dir: Base directory for resolving relative paths.
175
+ """
176
+ if not isinstance(data, dict):
177
+ raise ConfigValidationError(f"Target at index {index} must be a dict, got: {type(data)}")
178
+
179
+ target_type = data.get("type")
180
+ if target_type != "claude":
181
+ raise ConfigValidationError(
182
+ f"Target at index {index} type must be 'claude', got: {target_type}"
183
+ )
184
+
185
+ config_data = data.get("config", {})
186
+ claude_config = _parse_claude_config(config_data, base_dir)
187
+
188
+ return TargetConfig(type=target_type, config=claude_config)
189
+
190
+
191
+ def load_config(config_path: Path | None = None) -> AIConfig:
192
+ """Load and validate ai-config from a YAML file.
193
+
194
+ Args:
195
+ config_path: Explicit path to config file. If None, searches default locations.
196
+
197
+ Returns:
198
+ Validated AIConfig object.
199
+
200
+ Raises:
201
+ ConfigNotFoundError: If config file is not found.
202
+ ConfigParseError: If YAML parsing fails.
203
+ ConfigValidationError: If schema validation fails.
204
+ """
205
+ path = find_config_file(config_path)
206
+
207
+ try:
208
+ with open(path) as f:
209
+ raw_config = yaml.safe_load(f)
210
+ except yaml.YAMLError as e:
211
+ raise ConfigParseError(f"Failed to parse YAML from {path}: {e}") from e
212
+
213
+ if raw_config is None:
214
+ raise ConfigValidationError(f"Config file is empty: {path}")
215
+
216
+ if not isinstance(raw_config, dict):
217
+ raise ConfigValidationError(f"Config must be a dict, got: {type(raw_config)}")
218
+
219
+ version = raw_config.get("version")
220
+ if version != 1:
221
+ raise ConfigValidationError(f"Config version must be 1, got: {version}")
222
+
223
+ # Resolve the base directory for relative paths
224
+ # Use the parent of the config file, then go up one more level
225
+ # (config is in .ai-config/config.yaml, so base is the repo root)
226
+ base_dir = path.resolve().parent.parent
227
+
228
+ targets: list[TargetConfig] = []
229
+ raw_targets = raw_config.get("targets", [])
230
+ if raw_targets:
231
+ if not isinstance(raw_targets, list):
232
+ raise ConfigValidationError(f"Targets must be a list, got: {type(raw_targets)}")
233
+ for i, target_data in enumerate(raw_targets):
234
+ targets.append(_parse_target(target_data, i, base_dir))
235
+
236
+ return AIConfig(version=version, targets=tuple(targets))
237
+
238
+
239
+ def validate_marketplace_references(config: AIConfig) -> list[str]:
240
+ """Validate that all plugin marketplace references exist.
241
+
242
+ Args:
243
+ config: The AIConfig to validate.
244
+
245
+ Returns:
246
+ List of validation error messages (empty if valid).
247
+ """
248
+ errors: list[str] = []
249
+
250
+ for target in config.targets:
251
+ if target.type == "claude":
252
+ available_marketplaces = set(target.config.marketplaces.keys())
253
+ for plugin in target.config.plugins:
254
+ if plugin.marketplace and plugin.marketplace not in available_marketplaces:
255
+ errors.append(
256
+ f"Plugin '{plugin.id}' references undefined marketplace "
257
+ f"'{plugin.marketplace}'. Available: {sorted(available_marketplaces)}"
258
+ )
259
+
260
+ return errors