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/init.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Interactive init wizard for ai-config.
|
|
2
|
+
|
|
3
|
+
This module provides the `ai-config init` command that creates a new
|
|
4
|
+
.ai-config/config.yaml file through an interactive wizard experience.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import questionary
|
|
14
|
+
import requests
|
|
15
|
+
import yaml
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.panel import Panel
|
|
18
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
19
|
+
|
|
20
|
+
from ai_config.cli_theme import SYMBOLS
|
|
21
|
+
|
|
22
|
+
# Scope choices with descriptions for user selection
|
|
23
|
+
SCOPE_CHOICES: dict[str, str] = {
|
|
24
|
+
"user": "Available in all projects (~/.claude/plugins/)",
|
|
25
|
+
"project": "Only in this project (.claude/plugins/)",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_github_repo(input_str: str) -> str | None:
|
|
30
|
+
"""Parse a GitHub repo from various input formats.
|
|
31
|
+
|
|
32
|
+
Accepts:
|
|
33
|
+
- owner/repo (simple slug)
|
|
34
|
+
- https://github.com/owner/repo
|
|
35
|
+
- https://github.com/owner/repo.git
|
|
36
|
+
- https://github.com/owner/repo/tree/main/...
|
|
37
|
+
- git@github.com:owner/repo.git
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
input_str: User input that might be a GitHub repo.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Normalized owner/repo string, or None if invalid.
|
|
44
|
+
"""
|
|
45
|
+
if not input_str:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
input_str = input_str.strip()
|
|
49
|
+
|
|
50
|
+
# Handle simple owner/repo format
|
|
51
|
+
if "/" in input_str and not input_str.startswith(("http", "git@")):
|
|
52
|
+
parts = input_str.split("/")
|
|
53
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
54
|
+
return input_str
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
# Handle HTTPS URLs: https://github.com/owner/repo[.git][/...]
|
|
58
|
+
if input_str.startswith("https://github.com/"):
|
|
59
|
+
path = input_str.replace("https://github.com/", "")
|
|
60
|
+
path = path.rstrip("/")
|
|
61
|
+
if path.endswith(".git"):
|
|
62
|
+
path = path[:-4]
|
|
63
|
+
parts = path.split("/")
|
|
64
|
+
if len(parts) >= 2 and parts[0] and parts[1]:
|
|
65
|
+
return f"{parts[0]}/{parts[1]}"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
# Handle SSH URLs: git@github.com:owner/repo.git
|
|
69
|
+
if input_str.startswith("git@github.com:"):
|
|
70
|
+
path = input_str.replace("git@github.com:", "")
|
|
71
|
+
if path.endswith(".git"):
|
|
72
|
+
path = path[:-4]
|
|
73
|
+
parts = path.split("/")
|
|
74
|
+
if len(parts) == 2 and parts[0] and parts[1]:
|
|
75
|
+
return f"{parts[0]}/{parts[1]}"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PluginInfo:
|
|
83
|
+
"""Information about a discovered plugin."""
|
|
84
|
+
|
|
85
|
+
id: str
|
|
86
|
+
description: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class MarketplaceChoice:
|
|
91
|
+
"""A marketplace selection during init."""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
source: Literal["github", "local"]
|
|
95
|
+
repo: str = ""
|
|
96
|
+
path: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class PluginChoice:
|
|
101
|
+
"""A plugin selection during init."""
|
|
102
|
+
|
|
103
|
+
id: str
|
|
104
|
+
marketplace: str
|
|
105
|
+
enabled: bool = True
|
|
106
|
+
scope: str = "user"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class InitConfig:
|
|
111
|
+
"""Collected user choices during init wizard."""
|
|
112
|
+
|
|
113
|
+
config_path: Path
|
|
114
|
+
marketplaces: list[MarketplaceChoice] = field(default_factory=list)
|
|
115
|
+
plugins: list[PluginChoice] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_claude_cli() -> tuple[bool, str]:
|
|
119
|
+
"""Check if Claude CLI is installed and get version.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Tuple of (is_installed, version_or_error_message).
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["claude", "--version"],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
timeout=10,
|
|
130
|
+
)
|
|
131
|
+
if result.returncode == 0:
|
|
132
|
+
version = result.stdout.strip()
|
|
133
|
+
return True, version
|
|
134
|
+
return False, result.stderr.strip() or "Unknown error"
|
|
135
|
+
except FileNotFoundError:
|
|
136
|
+
return False, "Claude CLI not found"
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
return False, "Claude CLI timed out"
|
|
139
|
+
except OSError as e:
|
|
140
|
+
return False, str(e)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_marketplace_name(path: Path) -> str | None:
|
|
144
|
+
"""Get the marketplace name from its marketplace.json file.
|
|
145
|
+
|
|
146
|
+
Claude CLI uses the name from marketplace.json, not user-provided names.
|
|
147
|
+
This function reads that name so we can use it correctly.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
path: Path to the marketplace directory.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The marketplace name, or None if it can't be read.
|
|
154
|
+
"""
|
|
155
|
+
marketplace_json = path / ".claude-plugin" / "marketplace.json"
|
|
156
|
+
|
|
157
|
+
if not marketplace_json.exists():
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(marketplace_json.read_text())
|
|
162
|
+
return data.get("name")
|
|
163
|
+
except (json.JSONDecodeError, OSError):
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def discover_plugins_from_local(path: Path) -> list[PluginInfo]:
|
|
168
|
+
"""Discover plugins from a local marketplace directory.
|
|
169
|
+
|
|
170
|
+
Reads the .claude-plugin/marketplace.json file to find available plugins.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
path: Path to the marketplace directory.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of PluginInfo for each plugin found, empty list on error.
|
|
177
|
+
"""
|
|
178
|
+
marketplace_json = path / ".claude-plugin" / "marketplace.json"
|
|
179
|
+
|
|
180
|
+
if not marketplace_json.exists():
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
data = json.loads(marketplace_json.read_text())
|
|
185
|
+
plugins_data = data.get("plugins", [])
|
|
186
|
+
|
|
187
|
+
return [
|
|
188
|
+
PluginInfo(
|
|
189
|
+
id=p.get("name", ""),
|
|
190
|
+
description=p.get("description", ""),
|
|
191
|
+
)
|
|
192
|
+
for p in plugins_data
|
|
193
|
+
if p.get("name")
|
|
194
|
+
]
|
|
195
|
+
except (json.JSONDecodeError, OSError):
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def discover_plugins_from_github(repo: str) -> list[PluginInfo]:
|
|
200
|
+
"""Discover plugins from a GitHub marketplace repository.
|
|
201
|
+
|
|
202
|
+
Fetches the .claude-plugin/marketplace.json file from the repo.
|
|
203
|
+
Tries 'main' branch first, then 'master'.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
repo: GitHub repo in owner/repo format.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of PluginInfo for each plugin found, empty list on error.
|
|
210
|
+
"""
|
|
211
|
+
branches = ["main", "master"]
|
|
212
|
+
|
|
213
|
+
for branch in branches:
|
|
214
|
+
url = f"https://raw.githubusercontent.com/{repo}/{branch}/.claude-plugin/marketplace.json"
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
response = requests.get(url, timeout=10)
|
|
218
|
+
if response.status_code == 200:
|
|
219
|
+
data = response.json()
|
|
220
|
+
plugins_data = data.get("plugins", [])
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
PluginInfo(
|
|
224
|
+
id=p.get("name", ""),
|
|
225
|
+
description=p.get("description", ""),
|
|
226
|
+
)
|
|
227
|
+
for p in plugins_data
|
|
228
|
+
if p.get("name")
|
|
229
|
+
]
|
|
230
|
+
except Exception:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
return []
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def find_local_marketplaces(search_path: Path, max_depth: int = 4) -> list[Path]:
|
|
237
|
+
"""Search for local marketplace directories.
|
|
238
|
+
|
|
239
|
+
Looks for directories containing .claude-plugin/marketplace.json.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
search_path: Directory to search from.
|
|
243
|
+
max_depth: Maximum directory depth to search.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of paths to marketplace directories (parent of .claude-plugin).
|
|
247
|
+
"""
|
|
248
|
+
results: list[Path] = []
|
|
249
|
+
|
|
250
|
+
def search_recursive(current: Path, depth: int) -> None:
|
|
251
|
+
if depth > max_depth:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
marketplace_json = current / ".claude-plugin" / "marketplace.json"
|
|
255
|
+
if marketplace_json.exists():
|
|
256
|
+
results.append(current)
|
|
257
|
+
return # Don't search inside a marketplace
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
for child in current.iterdir():
|
|
261
|
+
if child.is_dir() and not child.name.startswith("."):
|
|
262
|
+
search_recursive(child, depth + 1)
|
|
263
|
+
except PermissionError:
|
|
264
|
+
pass
|
|
265
|
+
|
|
266
|
+
search_recursive(search_path, 0)
|
|
267
|
+
return results
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def fetch_marketplace_plugins(
|
|
271
|
+
source: Literal["github", "local"],
|
|
272
|
+
repo: str = "",
|
|
273
|
+
path: str = "",
|
|
274
|
+
) -> list[PluginInfo]:
|
|
275
|
+
"""Fetch available plugins from a marketplace.
|
|
276
|
+
|
|
277
|
+
Uses the new discovery functions to read marketplace.json directly.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
source: Either 'github' or 'local'.
|
|
281
|
+
repo: GitHub repo in owner/repo format (for github source).
|
|
282
|
+
path: Local filesystem path (for local source).
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
List of PluginInfo for each plugin found, empty if fetch fails.
|
|
286
|
+
"""
|
|
287
|
+
if source == "github":
|
|
288
|
+
return discover_plugins_from_github(repo)
|
|
289
|
+
else:
|
|
290
|
+
return discover_plugins_from_local(Path(path))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def prompt_select(message: str, choices: list[str], default: str | None = None) -> str | None:
|
|
294
|
+
"""Interactive select prompt using questionary.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
message: The prompt message.
|
|
298
|
+
choices: List of choices to display.
|
|
299
|
+
default: Default selection.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Selected choice string, or None if cancelled.
|
|
303
|
+
"""
|
|
304
|
+
return questionary.select(
|
|
305
|
+
message,
|
|
306
|
+
choices=choices,
|
|
307
|
+
default=default,
|
|
308
|
+
).ask()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def prompt_checkbox(
|
|
312
|
+
message: str,
|
|
313
|
+
choices: list[tuple[str, str]],
|
|
314
|
+
checked_by_default: bool = True,
|
|
315
|
+
) -> list[str] | None:
|
|
316
|
+
"""Interactive checkbox prompt using questionary.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
message: The prompt message.
|
|
320
|
+
choices: List of (value, label) tuples.
|
|
321
|
+
checked_by_default: Whether items are checked by default.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
List of selected values, or None if cancelled.
|
|
325
|
+
"""
|
|
326
|
+
q_choices = [
|
|
327
|
+
questionary.Choice(title=label, value=value, checked=checked_by_default)
|
|
328
|
+
for value, label in choices
|
|
329
|
+
]
|
|
330
|
+
return questionary.checkbox(message, choices=q_choices).ask()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def prompt_text(message: str, default: str = "") -> str | None:
|
|
334
|
+
"""Interactive text prompt using questionary.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
message: The prompt message.
|
|
338
|
+
default: Default value.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Entered text, or None if cancelled.
|
|
342
|
+
"""
|
|
343
|
+
return questionary.text(message, default=default).ask()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def prompt_confirm(message: str, default: bool = True) -> bool | None:
|
|
347
|
+
"""Interactive confirm prompt using questionary.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
message: The prompt message.
|
|
351
|
+
default: Default value.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
True/False, or None if cancelled.
|
|
355
|
+
"""
|
|
356
|
+
return questionary.confirm(message, default=default).ask()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def prompt_path_with_search(
|
|
360
|
+
console: Console,
|
|
361
|
+
search_from: Path | None = None,
|
|
362
|
+
) -> Path | None:
|
|
363
|
+
"""Prompt for a local path with optional marketplace search.
|
|
364
|
+
|
|
365
|
+
Offers to search for existing marketplace.json files.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
console: Rich console for output.
|
|
369
|
+
search_from: Directory to search from (defaults to cwd).
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Selected path, or None if cancelled.
|
|
373
|
+
"""
|
|
374
|
+
search_path = search_from or Path.cwd()
|
|
375
|
+
|
|
376
|
+
# First, offer to search for existing marketplaces
|
|
377
|
+
should_search = prompt_confirm(
|
|
378
|
+
f"Search for marketplaces in {search_path}?",
|
|
379
|
+
default=True,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if should_search is None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
if should_search:
|
|
386
|
+
console.print()
|
|
387
|
+
with Progress(
|
|
388
|
+
SpinnerColumn(),
|
|
389
|
+
TextColumn("[progress.description]{task.description}"),
|
|
390
|
+
console=console,
|
|
391
|
+
transient=True,
|
|
392
|
+
) as progress:
|
|
393
|
+
progress.add_task("Searching for marketplaces...", total=None)
|
|
394
|
+
found = find_local_marketplaces(search_path)
|
|
395
|
+
|
|
396
|
+
if found:
|
|
397
|
+
console.print(f" Found {len(found)} marketplace(s)")
|
|
398
|
+
console.print()
|
|
399
|
+
|
|
400
|
+
# Build choices from found marketplaces
|
|
401
|
+
choices = [str(p) for p in found]
|
|
402
|
+
choices.append("Enter path manually")
|
|
403
|
+
|
|
404
|
+
selected = prompt_select("Select a marketplace:", choices)
|
|
405
|
+
|
|
406
|
+
if selected is None:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
if selected != "Enter path manually":
|
|
410
|
+
return Path(selected)
|
|
411
|
+
|
|
412
|
+
# Manual path entry
|
|
413
|
+
console.print()
|
|
414
|
+
path_str = prompt_text("Enter local path:")
|
|
415
|
+
|
|
416
|
+
if path_str is None:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
return Path(path_str).expanduser().resolve()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def generate_config_yaml(init_config: InitConfig) -> str:
|
|
423
|
+
"""Generate YAML string from InitConfig.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
init_config: The collected configuration choices.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
YAML string ready to write to file.
|
|
430
|
+
"""
|
|
431
|
+
# Build marketplaces dict
|
|
432
|
+
marketplaces: dict[str, dict[str, str]] = {}
|
|
433
|
+
for mp in init_config.marketplaces:
|
|
434
|
+
if mp.source == "github":
|
|
435
|
+
marketplaces[mp.name] = {
|
|
436
|
+
"source": "github",
|
|
437
|
+
"repo": mp.repo,
|
|
438
|
+
}
|
|
439
|
+
else:
|
|
440
|
+
marketplaces[mp.name] = {
|
|
441
|
+
"source": "local",
|
|
442
|
+
"path": mp.path,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
# Build plugins list
|
|
446
|
+
plugins: list[dict[str, str | bool]] = []
|
|
447
|
+
for plugin in init_config.plugins:
|
|
448
|
+
plugins.append(
|
|
449
|
+
{
|
|
450
|
+
"id": f"{plugin.id}@{plugin.marketplace}",
|
|
451
|
+
"scope": plugin.scope,
|
|
452
|
+
"enabled": plugin.enabled,
|
|
453
|
+
}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Build config structure
|
|
457
|
+
config = {
|
|
458
|
+
"version": 1,
|
|
459
|
+
"targets": [
|
|
460
|
+
{
|
|
461
|
+
"type": "claude",
|
|
462
|
+
"config": {
|
|
463
|
+
"marketplaces": marketplaces,
|
|
464
|
+
"plugins": plugins,
|
|
465
|
+
},
|
|
466
|
+
}
|
|
467
|
+
],
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Generate YAML with nice formatting
|
|
471
|
+
return yaml.dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def write_config(init_config: InitConfig) -> Path:
|
|
475
|
+
"""Write config file, creating directories as needed.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
init_config: The configuration to write.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Path to the written config file.
|
|
482
|
+
"""
|
|
483
|
+
config_path = init_config.config_path
|
|
484
|
+
|
|
485
|
+
# Create parent directories
|
|
486
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
|
|
488
|
+
# Generate and write YAML
|
|
489
|
+
yaml_content = generate_config_yaml(init_config)
|
|
490
|
+
config_path.write_text(yaml_content)
|
|
491
|
+
|
|
492
|
+
return config_path
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def run_init_wizard(console: Console, output_path: Path | None = None) -> InitConfig | None:
|
|
496
|
+
"""Run the interactive init wizard.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
console: Rich console for output.
|
|
500
|
+
output_path: Optional explicit output path.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
InitConfig with collected choices, or None if cancelled.
|
|
504
|
+
"""
|
|
505
|
+
# Header
|
|
506
|
+
console.print()
|
|
507
|
+
console.print(Panel.fit("[header]ai-config init[/header]", border_style="cyan"))
|
|
508
|
+
console.print()
|
|
509
|
+
|
|
510
|
+
# Check prerequisites
|
|
511
|
+
console.print("Checking prerequisites...")
|
|
512
|
+
cli_installed, cli_version = check_claude_cli()
|
|
513
|
+
|
|
514
|
+
if cli_installed:
|
|
515
|
+
console.print(
|
|
516
|
+
f" [success]{SYMBOLS['pass']}[/success] Claude CLI installed ({cli_version})"
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
console.print(f" [error]{SYMBOLS['fail']}[/error] Claude CLI not found")
|
|
520
|
+
console.print()
|
|
521
|
+
console.print("[hint]Install Claude Code: npm install -g @anthropic-ai/claude-code[/hint]")
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
console.print()
|
|
525
|
+
|
|
526
|
+
# Choose config location
|
|
527
|
+
if output_path:
|
|
528
|
+
config_path = output_path
|
|
529
|
+
console.print(f"Config will be created at: {config_path}")
|
|
530
|
+
else:
|
|
531
|
+
location = prompt_select(
|
|
532
|
+
"Where should the config be created?",
|
|
533
|
+
choices=[
|
|
534
|
+
".ai-config/config.yaml (this project)",
|
|
535
|
+
"~/.ai-config/config.yaml (global)",
|
|
536
|
+
],
|
|
537
|
+
default=".ai-config/config.yaml (this project)",
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if location is None:
|
|
541
|
+
return None
|
|
542
|
+
|
|
543
|
+
if "this project" in location:
|
|
544
|
+
config_path = Path.cwd() / ".ai-config" / "config.yaml"
|
|
545
|
+
else:
|
|
546
|
+
config_path = Path.home() / ".ai-config" / "config.yaml"
|
|
547
|
+
|
|
548
|
+
# Check for existing config
|
|
549
|
+
if config_path.exists():
|
|
550
|
+
console.print()
|
|
551
|
+
console.print(f"[warning]Config already exists at {config_path}[/warning]")
|
|
552
|
+
overwrite = prompt_confirm("Overwrite existing config?", default=False)
|
|
553
|
+
if not overwrite:
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
console.print()
|
|
557
|
+
console.print("━" * 40)
|
|
558
|
+
console.print()
|
|
559
|
+
|
|
560
|
+
# Collect marketplaces and plugins
|
|
561
|
+
init_config = InitConfig(config_path=config_path)
|
|
562
|
+
|
|
563
|
+
while True:
|
|
564
|
+
mp_source = prompt_select(
|
|
565
|
+
"Add a marketplace? (marketplaces contain plugins you can install)",
|
|
566
|
+
choices=[
|
|
567
|
+
"GitHub repository",
|
|
568
|
+
"Local directory",
|
|
569
|
+
"Skip (no more marketplaces)",
|
|
570
|
+
],
|
|
571
|
+
default="GitHub repository",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
if mp_source is None or mp_source == "Skip (no more marketplaces)":
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
console.print()
|
|
578
|
+
|
|
579
|
+
if mp_source == "GitHub repository":
|
|
580
|
+
repo_input = prompt_text(
|
|
581
|
+
"GitHub repo (owner/repo or full URL):",
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if repo_input is None:
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
repo = parse_github_repo(repo_input)
|
|
588
|
+
if repo is None:
|
|
589
|
+
console.print("[warning]Invalid format. Examples:[/warning]")
|
|
590
|
+
console.print(" - owner/repo")
|
|
591
|
+
console.print(" - https://github.com/owner/repo")
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
# Suggest marketplace name from repo
|
|
595
|
+
suggested_name = repo.replace("/", "-")
|
|
596
|
+
name = prompt_text("Marketplace name:", default=suggested_name)
|
|
597
|
+
|
|
598
|
+
if name is None:
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
marketplace = MarketplaceChoice(
|
|
602
|
+
name=name,
|
|
603
|
+
source="github",
|
|
604
|
+
repo=repo,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
else: # Local directory
|
|
608
|
+
path = prompt_path_with_search(console)
|
|
609
|
+
|
|
610
|
+
if path is None:
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
if not path.exists():
|
|
614
|
+
console.print(f"[warning]Path does not exist: {path}[/warning]")
|
|
615
|
+
add_anyway = prompt_confirm("Add anyway?", default=True)
|
|
616
|
+
if not add_anyway:
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
# Read the actual marketplace name from marketplace.json
|
|
620
|
+
# Claude CLI uses this name, not user-provided names
|
|
621
|
+
actual_name = get_marketplace_name(path)
|
|
622
|
+
|
|
623
|
+
if actual_name:
|
|
624
|
+
console.print(
|
|
625
|
+
f" [info]Found marketplace name in manifest:[/info] [key]{actual_name}[/key]"
|
|
626
|
+
)
|
|
627
|
+
name = actual_name
|
|
628
|
+
else:
|
|
629
|
+
# Fallback to directory name if we can't read marketplace.json
|
|
630
|
+
console.print(" [warning]Could not read marketplace name from manifest[/warning]")
|
|
631
|
+
suggested_name = path.name
|
|
632
|
+
name = prompt_text("Marketplace name:", default=suggested_name)
|
|
633
|
+
|
|
634
|
+
if name is None:
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
marketplace = MarketplaceChoice(
|
|
638
|
+
name=name,
|
|
639
|
+
source="local",
|
|
640
|
+
path=str(path),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
init_config.marketplaces.append(marketplace)
|
|
644
|
+
|
|
645
|
+
# Show marketplace added confirmation
|
|
646
|
+
console.print()
|
|
647
|
+
console.print(
|
|
648
|
+
f"[success]{SYMBOLS['pass']}[/success] Added marketplace: [key]{marketplace.name}[/key]"
|
|
649
|
+
)
|
|
650
|
+
if marketplace.source == "github":
|
|
651
|
+
console.print(f" Source: github ({marketplace.repo})")
|
|
652
|
+
else:
|
|
653
|
+
console.print(f" Source: local ({marketplace.path})")
|
|
654
|
+
|
|
655
|
+
# Fetch and select plugins from this marketplace
|
|
656
|
+
console.print()
|
|
657
|
+
console.print("Discovering plugins...")
|
|
658
|
+
with Progress(
|
|
659
|
+
SpinnerColumn(),
|
|
660
|
+
TextColumn("[progress.description]{task.description}"),
|
|
661
|
+
console=console,
|
|
662
|
+
transient=True,
|
|
663
|
+
) as progress:
|
|
664
|
+
progress.add_task(f"Fetching plugins from {marketplace.name}...", total=None)
|
|
665
|
+
plugins = fetch_marketplace_plugins(
|
|
666
|
+
marketplace.source,
|
|
667
|
+
marketplace.repo,
|
|
668
|
+
marketplace.path,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if plugins:
|
|
672
|
+
console.print(f" [success]{SYMBOLS['pass']}[/success] Found {len(plugins)} plugin(s):")
|
|
673
|
+
for p in plugins:
|
|
674
|
+
desc = f" - {p.description}" if p.description else ""
|
|
675
|
+
console.print(f" {SYMBOLS['bullet']} {p.id}{desc}")
|
|
676
|
+
console.print()
|
|
677
|
+
|
|
678
|
+
# Build checkbox choices
|
|
679
|
+
choices = [
|
|
680
|
+
(p.id, f"{p.id} - {p.description}" if p.description else p.id) for p in plugins
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
selected = prompt_checkbox(
|
|
684
|
+
"Select plugins to enable:",
|
|
685
|
+
choices,
|
|
686
|
+
checked_by_default=True,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
if selected is None:
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
if selected:
|
|
693
|
+
# Ask for scope with explanation
|
|
694
|
+
console.print()
|
|
695
|
+
scope_choices = [f"{scope} - {desc}" for scope, desc in SCOPE_CHOICES.items()]
|
|
696
|
+
scope_selection = prompt_select(
|
|
697
|
+
"Where should plugins be installed?",
|
|
698
|
+
choices=scope_choices,
|
|
699
|
+
default=scope_choices[0], # user is first/default
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if scope_selection is None:
|
|
703
|
+
return None
|
|
704
|
+
|
|
705
|
+
# Extract scope from selection (first word)
|
|
706
|
+
selected_scope = scope_selection.split(" - ")[0]
|
|
707
|
+
|
|
708
|
+
for plugin_id in selected:
|
|
709
|
+
init_config.plugins.append(
|
|
710
|
+
PluginChoice(
|
|
711
|
+
id=plugin_id,
|
|
712
|
+
marketplace=marketplace.name,
|
|
713
|
+
enabled=True,
|
|
714
|
+
scope=selected_scope,
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
else:
|
|
718
|
+
console.print(
|
|
719
|
+
f" [warning]{SYMBOLS['warn']}[/warning] No plugins found in marketplace.json"
|
|
720
|
+
)
|
|
721
|
+
console.print(" (The marketplace was added but contains no plugins yet)")
|
|
722
|
+
|
|
723
|
+
console.print()
|
|
724
|
+
|
|
725
|
+
add_another = prompt_confirm("Add another marketplace?", default=False)
|
|
726
|
+
if not add_another:
|
|
727
|
+
break
|
|
728
|
+
|
|
729
|
+
console.print()
|
|
730
|
+
|
|
731
|
+
console.print()
|
|
732
|
+
console.print("━" * 40)
|
|
733
|
+
console.print()
|
|
734
|
+
|
|
735
|
+
# Show preview
|
|
736
|
+
console.print("[subheader]Config preview:[/subheader]")
|
|
737
|
+
console.print()
|
|
738
|
+
yaml_preview = generate_config_yaml(init_config)
|
|
739
|
+
console.print(yaml_preview)
|
|
740
|
+
|
|
741
|
+
# Confirm write
|
|
742
|
+
write_ok = prompt_confirm(f"Write config to {config_path}?", default=True)
|
|
743
|
+
if not write_ok:
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
return init_config
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def create_minimal_config(output_path: Path | None = None) -> InitConfig:
|
|
750
|
+
"""Create a minimal config without prompts.
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
output_path: Optional explicit output path.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
InitConfig with minimal/empty configuration.
|
|
757
|
+
"""
|
|
758
|
+
if output_path:
|
|
759
|
+
config_path = output_path
|
|
760
|
+
else:
|
|
761
|
+
config_path = Path.cwd() / ".ai-config" / "config.yaml"
|
|
762
|
+
|
|
763
|
+
return InitConfig(config_path=config_path)
|