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