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.
@@ -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)