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/__init__.py +3 -0
- ai_config/__main__.py +6 -0
- ai_config/adapters/__init__.py +1 -0
- ai_config/adapters/claude.py +353 -0
- ai_config/cli.py +729 -0
- ai_config/cli_render.py +525 -0
- ai_config/cli_theme.py +44 -0
- ai_config/config.py +260 -0
- ai_config/init.py +763 -0
- ai_config/operations.py +357 -0
- ai_config/scaffold.py +87 -0
- ai_config/settings.py +63 -0
- ai_config/types.py +143 -0
- ai_config/validators/__init__.py +149 -0
- ai_config/validators/base.py +48 -0
- ai_config/validators/component/__init__.py +1 -0
- ai_config/validators/component/hook.py +366 -0
- ai_config/validators/component/mcp.py +230 -0
- ai_config/validators/component/skill.py +411 -0
- ai_config/validators/context.py +69 -0
- ai_config/validators/marketplace/__init__.py +1 -0
- ai_config/validators/marketplace/validators.py +433 -0
- ai_config/validators/plugin/__init__.py +1 -0
- ai_config/validators/plugin/validators.py +336 -0
- ai_config/validators/target/__init__.py +1 -0
- ai_config/validators/target/claude.py +154 -0
- ai_config/watch.py +279 -0
- ai_config_cli-0.1.0.dist-info/METADATA +235 -0
- ai_config_cli-0.1.0.dist-info/RECORD +32 -0
- ai_config_cli-0.1.0.dist-info/WHEEL +4 -0
- ai_config_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ai_config_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ai_config/operations.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Core operations for ai-config: sync, status, update."""
|
|
2
|
+
|
|
3
|
+
from ai_config.adapters import claude
|
|
4
|
+
from ai_config.types import (
|
|
5
|
+
AIConfig,
|
|
6
|
+
ClaudeTargetConfig,
|
|
7
|
+
PluginSource,
|
|
8
|
+
PluginStatus,
|
|
9
|
+
StatusResult,
|
|
10
|
+
SyncAction,
|
|
11
|
+
SyncResult,
|
|
12
|
+
TargetConfig,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sync_marketplaces(
|
|
17
|
+
config: ClaudeTargetConfig,
|
|
18
|
+
dry_run: bool = False,
|
|
19
|
+
) -> tuple[list[SyncAction], list[str]]:
|
|
20
|
+
"""Sync marketplaces to match config.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Tuple of (actions taken, errors).
|
|
24
|
+
"""
|
|
25
|
+
actions: list[SyncAction] = []
|
|
26
|
+
errors: list[str] = []
|
|
27
|
+
|
|
28
|
+
# Get currently installed marketplaces
|
|
29
|
+
installed_mps, mp_errors = claude.list_installed_marketplaces()
|
|
30
|
+
if mp_errors:
|
|
31
|
+
return [], mp_errors
|
|
32
|
+
|
|
33
|
+
installed_names = {mp.name for mp in installed_mps}
|
|
34
|
+
|
|
35
|
+
# Add missing marketplaces
|
|
36
|
+
for name, marketplace_config in config.marketplaces.items():
|
|
37
|
+
if name not in installed_names:
|
|
38
|
+
if marketplace_config.source == PluginSource.LOCAL:
|
|
39
|
+
source_desc = marketplace_config.path
|
|
40
|
+
else:
|
|
41
|
+
source_desc = marketplace_config.repo
|
|
42
|
+
|
|
43
|
+
action = SyncAction(
|
|
44
|
+
action="register_marketplace",
|
|
45
|
+
target=name,
|
|
46
|
+
reason=f"Add marketplace from {source_desc}",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not dry_run:
|
|
50
|
+
is_github = marketplace_config.source == PluginSource.GITHUB
|
|
51
|
+
repo = marketplace_config.repo if is_github else None
|
|
52
|
+
path = marketplace_config.path if not is_github else None
|
|
53
|
+
result = claude.add_marketplace(repo=repo, name=name, path=path)
|
|
54
|
+
if not result.success:
|
|
55
|
+
errors.append(f"Failed to add marketplace '{name}': {result.stderr}")
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
actions.append(action)
|
|
59
|
+
|
|
60
|
+
return actions, errors
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sync_plugins(
|
|
64
|
+
config: ClaudeTargetConfig,
|
|
65
|
+
dry_run: bool = False,
|
|
66
|
+
) -> tuple[list[SyncAction], list[str]]:
|
|
67
|
+
"""Sync plugins to match config.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (actions taken, errors).
|
|
71
|
+
"""
|
|
72
|
+
actions: list[SyncAction] = []
|
|
73
|
+
errors: list[str] = []
|
|
74
|
+
|
|
75
|
+
# Get currently installed plugins
|
|
76
|
+
installed_plugins, plugin_errors = claude.list_installed_plugins()
|
|
77
|
+
if plugin_errors:
|
|
78
|
+
return [], plugin_errors
|
|
79
|
+
|
|
80
|
+
installed_by_id = {p.id: p for p in installed_plugins}
|
|
81
|
+
|
|
82
|
+
# Process each plugin in config
|
|
83
|
+
for plugin_config in config.plugins:
|
|
84
|
+
plugin_id = plugin_config.id
|
|
85
|
+
installed = installed_by_id.get(plugin_id)
|
|
86
|
+
|
|
87
|
+
if installed is None:
|
|
88
|
+
# Plugin not installed - install it
|
|
89
|
+
if plugin_config.enabled:
|
|
90
|
+
action = SyncAction(
|
|
91
|
+
action="install",
|
|
92
|
+
target=plugin_id,
|
|
93
|
+
scope=plugin_config.scope,
|
|
94
|
+
reason="Plugin not installed",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not dry_run:
|
|
98
|
+
result = claude.install_plugin(plugin_id, plugin_config.scope)
|
|
99
|
+
if not result.success:
|
|
100
|
+
errors.append(f"Failed to install '{plugin_id}': {result.stderr}")
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
actions.append(action)
|
|
104
|
+
else:
|
|
105
|
+
# Plugin installed - check enabled state
|
|
106
|
+
if plugin_config.enabled and not installed.enabled:
|
|
107
|
+
action = SyncAction(
|
|
108
|
+
action="enable",
|
|
109
|
+
target=plugin_id,
|
|
110
|
+
reason="Plugin should be enabled",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if not dry_run:
|
|
114
|
+
result = claude.enable_plugin(plugin_id)
|
|
115
|
+
if not result.success:
|
|
116
|
+
errors.append(f"Failed to enable '{plugin_id}': {result.stderr}")
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
actions.append(action)
|
|
120
|
+
|
|
121
|
+
elif not plugin_config.enabled and installed.enabled:
|
|
122
|
+
action = SyncAction(
|
|
123
|
+
action="disable",
|
|
124
|
+
target=plugin_id,
|
|
125
|
+
reason="Plugin should be disabled",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if not dry_run:
|
|
129
|
+
result = claude.disable_plugin(plugin_id)
|
|
130
|
+
if not result.success:
|
|
131
|
+
errors.append(f"Failed to disable '{plugin_id}': {result.stderr}")
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
actions.append(action)
|
|
135
|
+
|
|
136
|
+
return actions, errors
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sync_target(
|
|
140
|
+
target: TargetConfig,
|
|
141
|
+
dry_run: bool = False,
|
|
142
|
+
fresh: bool = False,
|
|
143
|
+
) -> SyncResult:
|
|
144
|
+
"""Sync a target to match its config.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
target: Target configuration to sync.
|
|
148
|
+
dry_run: If True, only report what would be done.
|
|
149
|
+
fresh: If True, clear cache before syncing.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
SyncResult with actions taken and any errors.
|
|
153
|
+
"""
|
|
154
|
+
if target.type != "claude":
|
|
155
|
+
return SyncResult(
|
|
156
|
+
success=False,
|
|
157
|
+
errors=[f"v1 only supports 'claude', got: {target.type}"],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
result = SyncResult()
|
|
161
|
+
|
|
162
|
+
# Clear cache if fresh mode
|
|
163
|
+
if fresh and not dry_run:
|
|
164
|
+
cache_result = claude.clear_cache()
|
|
165
|
+
if not cache_result.success:
|
|
166
|
+
result.errors.append(f"Failed to clear cache: {cache_result.stderr}")
|
|
167
|
+
|
|
168
|
+
# Sync marketplaces first (plugins depend on them)
|
|
169
|
+
mp_actions, mp_errors = _sync_marketplaces(target.config, dry_run)
|
|
170
|
+
for action in mp_actions:
|
|
171
|
+
result.add_success(action)
|
|
172
|
+
result.errors.extend(mp_errors)
|
|
173
|
+
|
|
174
|
+
# Sync plugins
|
|
175
|
+
plugin_actions, plugin_errors = _sync_plugins(target.config, dry_run)
|
|
176
|
+
for action in plugin_actions:
|
|
177
|
+
result.add_success(action)
|
|
178
|
+
result.errors.extend(plugin_errors)
|
|
179
|
+
|
|
180
|
+
# If there were any errors, mark as failed
|
|
181
|
+
if result.errors:
|
|
182
|
+
result.success = False
|
|
183
|
+
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def sync_config(
|
|
188
|
+
config: AIConfig,
|
|
189
|
+
dry_run: bool = False,
|
|
190
|
+
fresh: bool = False,
|
|
191
|
+
) -> dict[str, SyncResult]:
|
|
192
|
+
"""Sync all targets in a config.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
config: Configuration to sync.
|
|
196
|
+
dry_run: If True, only report what would be done.
|
|
197
|
+
fresh: If True, clear cache before syncing.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dict mapping target type to SyncResult.
|
|
201
|
+
"""
|
|
202
|
+
results: dict[str, SyncResult] = {}
|
|
203
|
+
|
|
204
|
+
for target in config.targets:
|
|
205
|
+
results[target.type] = sync_target(target, dry_run, fresh)
|
|
206
|
+
|
|
207
|
+
return results
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_status(target_type: str = "claude") -> StatusResult:
|
|
211
|
+
"""Get current status of plugins and marketplaces.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
target_type: Target to get status for (only "claude" supported).
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
StatusResult with current state.
|
|
218
|
+
"""
|
|
219
|
+
if target_type != "claude":
|
|
220
|
+
return StatusResult(
|
|
221
|
+
target_type="claude",
|
|
222
|
+
errors=[f"v1 only supports 'claude', got: {target_type}"],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
result = StatusResult(target_type="claude")
|
|
226
|
+
|
|
227
|
+
# Get plugins
|
|
228
|
+
plugins, plugin_errors = claude.list_installed_plugins()
|
|
229
|
+
result.errors.extend(plugin_errors)
|
|
230
|
+
|
|
231
|
+
for plugin in plugins:
|
|
232
|
+
result.plugins.append(
|
|
233
|
+
PluginStatus(
|
|
234
|
+
id=plugin.id,
|
|
235
|
+
installed=True,
|
|
236
|
+
enabled=plugin.enabled,
|
|
237
|
+
scope=plugin.scope,
|
|
238
|
+
version=plugin.version,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Get marketplaces
|
|
243
|
+
marketplaces, mp_errors = claude.list_installed_marketplaces()
|
|
244
|
+
result.errors.extend(mp_errors)
|
|
245
|
+
|
|
246
|
+
for mp in marketplaces:
|
|
247
|
+
result.marketplaces.append(mp.name)
|
|
248
|
+
|
|
249
|
+
return result
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def update_plugins(
|
|
253
|
+
plugin_ids: list[str] | None = None,
|
|
254
|
+
fresh: bool = False,
|
|
255
|
+
) -> SyncResult:
|
|
256
|
+
"""Update plugins to latest versions.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
plugin_ids: Specific plugins to update, or None for all.
|
|
260
|
+
fresh: If True, clear cache before updating.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
SyncResult with update actions.
|
|
264
|
+
"""
|
|
265
|
+
result = SyncResult()
|
|
266
|
+
|
|
267
|
+
# Clear cache if fresh mode
|
|
268
|
+
if fresh:
|
|
269
|
+
cache_result = claude.clear_cache()
|
|
270
|
+
if not cache_result.success:
|
|
271
|
+
result.errors.append(f"Failed to clear cache: {cache_result.stderr}")
|
|
272
|
+
|
|
273
|
+
# Get installed plugins
|
|
274
|
+
installed, errors = claude.list_installed_plugins()
|
|
275
|
+
if errors:
|
|
276
|
+
result.errors.extend(errors)
|
|
277
|
+
result.success = False
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
# Determine which plugins to update
|
|
281
|
+
if plugin_ids is None:
|
|
282
|
+
plugins_to_update = [p.id for p in installed]
|
|
283
|
+
else:
|
|
284
|
+
installed_ids = {p.id for p in installed}
|
|
285
|
+
plugins_to_update = [pid for pid in plugin_ids if pid in installed_ids]
|
|
286
|
+
|
|
287
|
+
# Warn about plugins that aren't installed
|
|
288
|
+
for pid in plugin_ids:
|
|
289
|
+
if pid not in installed_ids:
|
|
290
|
+
result.errors.append(f"Plugin '{pid}' is not installed, skipping")
|
|
291
|
+
|
|
292
|
+
# Update each plugin
|
|
293
|
+
for plugin_id in plugins_to_update:
|
|
294
|
+
update_result = claude.update_plugin(plugin_id)
|
|
295
|
+
action = SyncAction(
|
|
296
|
+
action="install", # update is like reinstall
|
|
297
|
+
target=plugin_id,
|
|
298
|
+
reason="Update to latest version",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if update_result.success:
|
|
302
|
+
result.add_success(action)
|
|
303
|
+
else:
|
|
304
|
+
result.add_failure(action, update_result.stderr)
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def verify_sync(config: AIConfig) -> list[str]:
|
|
310
|
+
"""Verify that current state matches config.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
config: Configuration to verify against.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of discrepancies found (empty if in sync).
|
|
317
|
+
"""
|
|
318
|
+
discrepancies: list[str] = []
|
|
319
|
+
|
|
320
|
+
for target in config.targets:
|
|
321
|
+
if target.type != "claude":
|
|
322
|
+
discrepancies.append(f"Unknown target type: {target.type}")
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# Check marketplaces
|
|
326
|
+
installed_mps, mp_errors = claude.list_installed_marketplaces()
|
|
327
|
+
if mp_errors:
|
|
328
|
+
discrepancies.extend(mp_errors)
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
installed_mp_names = {mp.name for mp in installed_mps}
|
|
332
|
+
for name in target.config.marketplaces:
|
|
333
|
+
if name not in installed_mp_names:
|
|
334
|
+
discrepancies.append(f"Marketplace '{name}' is not registered")
|
|
335
|
+
|
|
336
|
+
# Check plugins
|
|
337
|
+
installed_plugins, plugin_errors = claude.list_installed_plugins()
|
|
338
|
+
if plugin_errors:
|
|
339
|
+
discrepancies.extend(plugin_errors)
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
installed_by_id = {p.id: p for p in installed_plugins}
|
|
343
|
+
|
|
344
|
+
for plugin_config in target.config.plugins:
|
|
345
|
+
plugin_id = plugin_config.id
|
|
346
|
+
installed = installed_by_id.get(plugin_id)
|
|
347
|
+
|
|
348
|
+
if installed is None:
|
|
349
|
+
if plugin_config.enabled:
|
|
350
|
+
discrepancies.append(f"Plugin '{plugin_id}' is not installed")
|
|
351
|
+
else:
|
|
352
|
+
if plugin_config.enabled and not installed.enabled:
|
|
353
|
+
discrepancies.append(f"Plugin '{plugin_id}' should be enabled")
|
|
354
|
+
elif not plugin_config.enabled and installed.enabled:
|
|
355
|
+
discrepancies.append(f"Plugin '{plugin_id}' should be disabled")
|
|
356
|
+
|
|
357
|
+
return discrepancies
|
ai_config/scaffold.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Plugin scaffolding for ai-config."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
MANIFEST_TEMPLATE = """name: {name}
|
|
6
|
+
version: 0.1.0
|
|
7
|
+
description: A Claude Code plugin
|
|
8
|
+
|
|
9
|
+
# Optional: Define MCP servers
|
|
10
|
+
# mcpServers:
|
|
11
|
+
# my-server:
|
|
12
|
+
# type: stdio
|
|
13
|
+
# command: npx
|
|
14
|
+
# args:
|
|
15
|
+
# - -y
|
|
16
|
+
# - my-mcp-server
|
|
17
|
+
|
|
18
|
+
# Optional: Define skills
|
|
19
|
+
# skills:
|
|
20
|
+
# - name: my-skill
|
|
21
|
+
# description: Does something useful
|
|
22
|
+
|
|
23
|
+
# Optional: Define hooks
|
|
24
|
+
# hooks:
|
|
25
|
+
# PreToolUse:
|
|
26
|
+
# - command: python3
|
|
27
|
+
# args:
|
|
28
|
+
# - hooks/pre_tool_use.py
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
SKILL_TEMPLATE = """---
|
|
32
|
+
name: {name}
|
|
33
|
+
description: A skill that does something useful
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# {name}
|
|
37
|
+
|
|
38
|
+
## When to Activate
|
|
39
|
+
|
|
40
|
+
- When the user asks about...
|
|
41
|
+
- When working with...
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
1. Check existing patterns
|
|
46
|
+
2. Follow the conventions
|
|
47
|
+
|
|
48
|
+
## Guardrails
|
|
49
|
+
|
|
50
|
+
- Do not...
|
|
51
|
+
- Always...
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_plugin(name: str, path: Path | None = None) -> Path:
|
|
56
|
+
"""Create a new plugin scaffold.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
name: Name of the plugin.
|
|
60
|
+
path: Base path for the plugin directory. Defaults to ~/.claude-plugins/
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Path to the created plugin directory.
|
|
64
|
+
"""
|
|
65
|
+
if path is None:
|
|
66
|
+
path = Path.home() / ".claude-plugins"
|
|
67
|
+
|
|
68
|
+
plugin_dir = path / name
|
|
69
|
+
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# Create manifest
|
|
72
|
+
manifest_path = plugin_dir / "manifest.yaml"
|
|
73
|
+
if not manifest_path.exists():
|
|
74
|
+
manifest_path.write_text(MANIFEST_TEMPLATE.format(name=name))
|
|
75
|
+
|
|
76
|
+
# Create directories
|
|
77
|
+
(plugin_dir / "skills").mkdir(exist_ok=True)
|
|
78
|
+
(plugin_dir / "hooks").mkdir(exist_ok=True)
|
|
79
|
+
|
|
80
|
+
# Create example skill
|
|
81
|
+
skill_dir = plugin_dir / "skills" / "example"
|
|
82
|
+
skill_dir.mkdir(exist_ok=True)
|
|
83
|
+
skill_file = skill_dir / "SKILL.md"
|
|
84
|
+
if not skill_file.exists():
|
|
85
|
+
skill_file.write_text(SKILL_TEMPLATE.format(name="example"))
|
|
86
|
+
|
|
87
|
+
return plugin_dir
|
ai_config/settings.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""JSON settings file manipulation with key preservation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
9
|
+
"""Read a JSON file, returning empty dict if file doesn't exist.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
path: Path to the JSON file.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Parsed JSON as a dict, or empty dict if file doesn't exist.
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
json.JSONDecodeError: If the file contains invalid JSON.
|
|
19
|
+
"""
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
content = f.read()
|
|
25
|
+
if not content.strip():
|
|
26
|
+
return {}
|
|
27
|
+
return json.loads(content)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
31
|
+
"""Write a dict to a JSON file, preserving formatting.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
path: Path to the JSON file.
|
|
35
|
+
data: Dict to write.
|
|
36
|
+
"""
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
with open(path, "w") as f:
|
|
39
|
+
json.dump(data, f, indent=2)
|
|
40
|
+
f.write("\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def merge_settings(base: dict[str, Any], updates: dict[str, Any]) -> dict[str, Any]:
|
|
44
|
+
"""Merge updates into base settings, preserving unknown keys.
|
|
45
|
+
|
|
46
|
+
Does a shallow merge at the top level, deep merge for nested dicts.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
base: The base settings dict.
|
|
50
|
+
updates: The updates to apply.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
New dict with updates merged in.
|
|
54
|
+
"""
|
|
55
|
+
result = base.copy()
|
|
56
|
+
|
|
57
|
+
for key, value in updates.items():
|
|
58
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
59
|
+
result[key] = merge_settings(result[key], value)
|
|
60
|
+
else:
|
|
61
|
+
result[key] = value
|
|
62
|
+
|
|
63
|
+
return result
|
ai_config/types.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Type definitions for ai-config."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PluginSource(str, Enum):
|
|
9
|
+
"""Source type for plugin marketplaces."""
|
|
10
|
+
|
|
11
|
+
GITHUB = "github"
|
|
12
|
+
LOCAL = "local"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MarketplaceConfig:
|
|
17
|
+
"""Configuration for a plugin marketplace."""
|
|
18
|
+
|
|
19
|
+
source: PluginSource
|
|
20
|
+
repo: str = ""
|
|
21
|
+
path: str = ""
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.source == PluginSource.GITHUB:
|
|
25
|
+
if not self.repo:
|
|
26
|
+
raise ValueError("Marketplace repo cannot be empty for github source")
|
|
27
|
+
if "/" not in self.repo:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Marketplace repo must be in 'owner/repo' format, got: {self.repo}"
|
|
30
|
+
)
|
|
31
|
+
elif self.source == PluginSource.LOCAL:
|
|
32
|
+
if not self.path:
|
|
33
|
+
raise ValueError("Marketplace path cannot be empty for local source")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class PluginConfig:
|
|
38
|
+
"""Configuration for a single plugin."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
scope: Literal["user", "project", "local"] = "user"
|
|
42
|
+
enabled: bool = True
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if not self.id:
|
|
46
|
+
raise ValueError("Plugin id cannot be empty")
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def marketplace(self) -> str | None:
|
|
50
|
+
"""Extract marketplace name from plugin id (format: plugin@marketplace)."""
|
|
51
|
+
if "@" in self.id:
|
|
52
|
+
return self.id.split("@", 1)[1]
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def plugin_name(self) -> str:
|
|
57
|
+
"""Extract plugin name without marketplace suffix."""
|
|
58
|
+
if "@" in self.id:
|
|
59
|
+
return self.id.split("@", 1)[0]
|
|
60
|
+
return self.id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ClaudeTargetConfig:
|
|
65
|
+
"""Configuration specific to Claude Code target."""
|
|
66
|
+
|
|
67
|
+
marketplaces: dict[str, MarketplaceConfig] = field(default_factory=dict)
|
|
68
|
+
plugins: tuple[PluginConfig, ...] = field(default_factory=tuple)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class TargetConfig:
|
|
73
|
+
"""Configuration for a target AI tool."""
|
|
74
|
+
|
|
75
|
+
type: Literal["claude"]
|
|
76
|
+
config: ClaudeTargetConfig
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
if self.type != "claude":
|
|
80
|
+
raise ValueError(f"v1 only supports 'claude', got: {self.type}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class AIConfig:
|
|
85
|
+
"""Root configuration for ai-config."""
|
|
86
|
+
|
|
87
|
+
version: int
|
|
88
|
+
targets: tuple[TargetConfig, ...] = field(default_factory=tuple)
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
if self.version != 1:
|
|
92
|
+
raise ValueError(f"Only version 1 is supported, got: {self.version}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class PluginStatus:
|
|
97
|
+
"""Status of a single plugin."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
installed: bool = False
|
|
101
|
+
enabled: bool = False
|
|
102
|
+
scope: Literal["user", "project", "local"] | None = None
|
|
103
|
+
version: str | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class SyncAction:
|
|
108
|
+
"""A single action to be taken during sync."""
|
|
109
|
+
|
|
110
|
+
action: Literal["install", "uninstall", "enable", "disable", "register_marketplace"]
|
|
111
|
+
target: str # plugin id or marketplace name
|
|
112
|
+
scope: Literal["user", "project", "local"] | None = None
|
|
113
|
+
reason: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class SyncResult:
|
|
118
|
+
"""Result of a sync operation."""
|
|
119
|
+
|
|
120
|
+
success: bool = True
|
|
121
|
+
actions_taken: list[SyncAction] = field(default_factory=list)
|
|
122
|
+
actions_failed: list[SyncAction] = field(default_factory=list)
|
|
123
|
+
errors: list[str] = field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
def add_success(self, action: SyncAction) -> None:
|
|
126
|
+
"""Record a successful action."""
|
|
127
|
+
self.actions_taken.append(action)
|
|
128
|
+
|
|
129
|
+
def add_failure(self, action: SyncAction, error: str) -> None:
|
|
130
|
+
"""Record a failed action."""
|
|
131
|
+
self.actions_failed.append(action)
|
|
132
|
+
self.errors.append(error)
|
|
133
|
+
self.success = False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class StatusResult:
|
|
138
|
+
"""Result of a status check."""
|
|
139
|
+
|
|
140
|
+
target_type: Literal["claude"]
|
|
141
|
+
plugins: list[PluginStatus] = field(default_factory=list)
|
|
142
|
+
marketplaces: list[str] = field(default_factory=list)
|
|
143
|
+
errors: list[str] = field(default_factory=list)
|