devsync 0.5.5__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.
Files changed (84) hide show
  1. aiconfigkit/__init__.py +0 -0
  2. aiconfigkit/__main__.py +6 -0
  3. aiconfigkit/ai_tools/__init__.py +0 -0
  4. aiconfigkit/ai_tools/base.py +236 -0
  5. aiconfigkit/ai_tools/capability_registry.py +262 -0
  6. aiconfigkit/ai_tools/claude.py +91 -0
  7. aiconfigkit/ai_tools/claude_desktop.py +97 -0
  8. aiconfigkit/ai_tools/cline.py +92 -0
  9. aiconfigkit/ai_tools/copilot.py +92 -0
  10. aiconfigkit/ai_tools/cursor.py +109 -0
  11. aiconfigkit/ai_tools/detector.py +169 -0
  12. aiconfigkit/ai_tools/kiro.py +85 -0
  13. aiconfigkit/ai_tools/mcp_syncer.py +291 -0
  14. aiconfigkit/ai_tools/roo.py +110 -0
  15. aiconfigkit/ai_tools/translator.py +390 -0
  16. aiconfigkit/ai_tools/winsurf.py +102 -0
  17. aiconfigkit/cli/__init__.py +0 -0
  18. aiconfigkit/cli/delete.py +118 -0
  19. aiconfigkit/cli/download.py +274 -0
  20. aiconfigkit/cli/install.py +237 -0
  21. aiconfigkit/cli/install_new.py +937 -0
  22. aiconfigkit/cli/list.py +275 -0
  23. aiconfigkit/cli/main.py +454 -0
  24. aiconfigkit/cli/mcp_configure.py +232 -0
  25. aiconfigkit/cli/mcp_install.py +166 -0
  26. aiconfigkit/cli/mcp_sync.py +165 -0
  27. aiconfigkit/cli/package.py +383 -0
  28. aiconfigkit/cli/package_create.py +323 -0
  29. aiconfigkit/cli/package_install.py +472 -0
  30. aiconfigkit/cli/template.py +19 -0
  31. aiconfigkit/cli/template_backup.py +261 -0
  32. aiconfigkit/cli/template_init.py +499 -0
  33. aiconfigkit/cli/template_install.py +261 -0
  34. aiconfigkit/cli/template_list.py +172 -0
  35. aiconfigkit/cli/template_uninstall.py +146 -0
  36. aiconfigkit/cli/template_update.py +225 -0
  37. aiconfigkit/cli/template_validate.py +234 -0
  38. aiconfigkit/cli/tools.py +47 -0
  39. aiconfigkit/cli/uninstall.py +125 -0
  40. aiconfigkit/cli/update.py +309 -0
  41. aiconfigkit/core/__init__.py +0 -0
  42. aiconfigkit/core/checksum.py +211 -0
  43. aiconfigkit/core/component_detector.py +905 -0
  44. aiconfigkit/core/conflict_resolution.py +329 -0
  45. aiconfigkit/core/git_operations.py +539 -0
  46. aiconfigkit/core/mcp/__init__.py +1 -0
  47. aiconfigkit/core/mcp/credentials.py +279 -0
  48. aiconfigkit/core/mcp/manager.py +308 -0
  49. aiconfigkit/core/mcp/set_manager.py +1 -0
  50. aiconfigkit/core/mcp/validator.py +1 -0
  51. aiconfigkit/core/models.py +1661 -0
  52. aiconfigkit/core/package_creator.py +743 -0
  53. aiconfigkit/core/package_manifest.py +248 -0
  54. aiconfigkit/core/repository.py +298 -0
  55. aiconfigkit/core/secret_detector.py +438 -0
  56. aiconfigkit/core/template_manifest.py +283 -0
  57. aiconfigkit/core/version.py +201 -0
  58. aiconfigkit/storage/__init__.py +0 -0
  59. aiconfigkit/storage/library.py +429 -0
  60. aiconfigkit/storage/mcp_tracker.py +1 -0
  61. aiconfigkit/storage/package_tracker.py +234 -0
  62. aiconfigkit/storage/template_library.py +229 -0
  63. aiconfigkit/storage/template_tracker.py +296 -0
  64. aiconfigkit/storage/tracker.py +416 -0
  65. aiconfigkit/tui/__init__.py +5 -0
  66. aiconfigkit/tui/installer.py +511 -0
  67. aiconfigkit/utils/__init__.py +0 -0
  68. aiconfigkit/utils/atomic_write.py +90 -0
  69. aiconfigkit/utils/backup.py +169 -0
  70. aiconfigkit/utils/dotenv.py +128 -0
  71. aiconfigkit/utils/git_helpers.py +187 -0
  72. aiconfigkit/utils/logging.py +60 -0
  73. aiconfigkit/utils/namespace.py +134 -0
  74. aiconfigkit/utils/paths.py +205 -0
  75. aiconfigkit/utils/project.py +109 -0
  76. aiconfigkit/utils/streaming.py +216 -0
  77. aiconfigkit/utils/ui.py +194 -0
  78. aiconfigkit/utils/validation.py +187 -0
  79. devsync-0.5.5.dist-info/LICENSE +21 -0
  80. devsync-0.5.5.dist-info/METADATA +477 -0
  81. devsync-0.5.5.dist-info/RECORD +84 -0
  82. devsync-0.5.5.dist-info/WHEEL +5 -0
  83. devsync-0.5.5.dist-info/entry_points.txt +2 -0
  84. devsync-0.5.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,937 @@
1
+ """Refactored install command with library support."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.prompt import Confirm
10
+
11
+ from aiconfigkit.ai_tools.base import AITool
12
+ from aiconfigkit.ai_tools.detector import AIToolDetector, get_detector
13
+ from aiconfigkit.core.conflict_resolution import ConflictResolver, prompt_conflict_resolution
14
+ from aiconfigkit.core.models import (
15
+ ConflictResolution,
16
+ InstallationRecord,
17
+ InstallationScope,
18
+ LibraryInstruction,
19
+ RefType,
20
+ )
21
+ from aiconfigkit.storage.library import LibraryManager
22
+ from aiconfigkit.storage.tracker import InstallationTracker
23
+ from aiconfigkit.tui.installer import show_installer_tui
24
+ from aiconfigkit.utils.project import find_project_root
25
+ from aiconfigkit.utils.ui import print_error, print_info, print_success
26
+
27
+ console = Console()
28
+
29
+
30
+ # ============================================================================
31
+ # Helper Functions - Shared Installation Logic
32
+ # ============================================================================
33
+
34
+
35
+ def _extract_ref_from_namespace(namespace: str) -> tuple[Optional[str], Optional[RefType]]:
36
+ """Extract Git reference from versioned namespace.
37
+
38
+ Args:
39
+ namespace: Repository namespace (e.g., 'github.com_owner_repo@v1.0.0')
40
+
41
+ Returns:
42
+ Tuple of (ref, ref_type) or (None, None) if no version info
43
+ """
44
+ if "@" not in namespace:
45
+ return (None, None)
46
+
47
+ # Split at @ to get the ref part
48
+ ref = namespace.split("@", 1)[1]
49
+
50
+ # Try to determine ref type from the ref format
51
+ # This is a best-effort detection since we don't have the original ref_type stored
52
+ import re
53
+
54
+ # Tags typically start with 'v' followed by numbers
55
+ if re.match(r"^v?\d+\.\d+", ref):
56
+ return (ref, RefType.TAG)
57
+ # Commit hashes are hex strings
58
+ elif re.match(r"^[0-9a-f]{7,40}$", ref):
59
+ return (ref, RefType.COMMIT)
60
+ # Everything else is likely a branch
61
+ else:
62
+ # Restore slashes that were converted to underscores
63
+ # This is approximate - feature_new might have been feature/new
64
+ return (ref, RefType.BRANCH)
65
+
66
+
67
+ def _parse_conflict_strategy(conflict_strategy: str) -> Optional[ConflictResolution]:
68
+ """Parse and validate conflict resolution strategy.
69
+
70
+ Args:
71
+ conflict_strategy: Strategy string (prompt, skip, rename, overwrite)
72
+
73
+ Returns:
74
+ ConflictResolution enum or None if invalid
75
+ """
76
+ try:
77
+ return ConflictResolution(conflict_strategy.lower())
78
+ except ValueError:
79
+ print_error(
80
+ f"Invalid conflict strategy: {conflict_strategy}. " "Must be 'prompt', 'skip', 'rename', or 'overwrite'."
81
+ )
82
+ return None
83
+
84
+
85
+ def _get_project_root_for_installation() -> Optional[Path]:
86
+ """Detect and validate project root for installation.
87
+
88
+ Returns:
89
+ Project root path or None if not found
90
+ """
91
+ project_root = find_project_root()
92
+ if not project_root:
93
+ print_error("Could not detect project root. " "Make sure you're running from within a project directory.")
94
+ return None
95
+ console.print(f"Detected project root: [cyan]{project_root}[/cyan]")
96
+ return project_root
97
+
98
+
99
+ def _load_instructions_from_library(instruction_ids: list[str], library: LibraryManager) -> Optional[list]:
100
+ """Load instructions from library by IDs.
101
+
102
+ Args:
103
+ instruction_ids: List of instruction IDs to load
104
+ library: Library manager instance
105
+
106
+ Returns:
107
+ List of LibraryInstruction objects or None if any not found
108
+ """
109
+ instructions = []
110
+ for inst_id in instruction_ids:
111
+ inst = library.get_instruction(inst_id)
112
+ if not inst:
113
+ print_error(f"Instruction not found in library: {inst_id}")
114
+ return None
115
+ instructions.append(inst)
116
+ return instructions
117
+
118
+
119
+ def _detect_installed_collisions(
120
+ instructions: list[LibraryInstruction],
121
+ ai_tools: list[AITool],
122
+ install_names: dict[str, str],
123
+ project_root: Optional[Path],
124
+ ) -> dict[str, list[InstallationRecord]]:
125
+ """Detect collisions with already-installed instructions.
126
+
127
+ Args:
128
+ instructions: List of instructions to install
129
+ ai_tools: List of AI tools to install to
130
+ install_names: Mapping of instruction IDs to install names
131
+ project_root: Project root path
132
+
133
+ Returns:
134
+ Dictionary mapping instruction_id to list of existing installations with same name
135
+ """
136
+ tracker = InstallationTracker()
137
+ collisions = {}
138
+
139
+ for inst in instructions:
140
+ install_name = install_names[inst.id]
141
+
142
+ # Check if this name is already used in any tool
143
+ existing = tracker.find_instructions_by_name(install_name, project_root)
144
+
145
+ # Filter to only collisions from different repositories
146
+ different_repo_collisions = [e for e in existing if e.source_repo != inst.repo_url]
147
+
148
+ if different_repo_collisions:
149
+ collisions[inst.id] = different_repo_collisions
150
+
151
+ return collisions
152
+
153
+
154
+ def _prompt_for_custom_filename(
155
+ instruction: LibraryInstruction,
156
+ existing_installations: list[InstallationRecord],
157
+ current_name: str,
158
+ ) -> Optional[str]:
159
+ """Prompt user to provide custom filename for collision resolution.
160
+
161
+ Args:
162
+ instruction: Instruction being installed
163
+ existing_installations: List of existing installations with same name
164
+ current_name: Current proposed install name
165
+
166
+ Returns:
167
+ Custom filename or None to skip installation
168
+ """
169
+ console.print(f"\n[yellow]⚠️ Name Collision:[/yellow] '{current_name}' is already installed")
170
+ console.print("\n[bold]Existing installations:[/bold]")
171
+ for existing in existing_installations:
172
+ repo_display = existing.source_repo or "unknown"
173
+ ref_display = existing.source_ref or "unknown"
174
+ console.print(f" • {existing.ai_tool.value}: {repo_display} @ {ref_display}")
175
+
176
+ console.print("\n[bold]New installation:[/bold]")
177
+ console.print(f" • Repository: {instruction.repo_url}")
178
+ console.print(f" • Namespace: {instruction.repo_namespace}")
179
+
180
+ console.print("\n[bold]Options:[/bold]")
181
+ console.print(" [1] Provide custom filename")
182
+ console.print(" [2] Skip this instruction")
183
+
184
+ choice = typer.prompt("Select (1-2)", default="2")
185
+
186
+ if choice == "1":
187
+ custom_name = typer.prompt(
188
+ "Enter custom filename (without extension)",
189
+ default=f"{instruction.repo_namespace.replace('@', '-').replace('/', '-')}_{instruction.name}",
190
+ )
191
+ return str(custom_name)
192
+ else:
193
+ return None
194
+
195
+
196
+ def _resolve_name_conflicts(instructions: list[LibraryInstruction]) -> Optional[dict[str, str]]:
197
+ """Resolve naming conflicts for instructions with duplicate names.
198
+
199
+ Args:
200
+ instructions: List of LibraryInstruction objects
201
+
202
+ Returns:
203
+ Dict mapping instruction ID to install name, or None if cancelled
204
+ """
205
+ # Check for name conflicts
206
+ name_conflicts: dict[str, list[LibraryInstruction]] = {}
207
+ for inst in instructions:
208
+ if inst.name not in name_conflicts:
209
+ name_conflicts[inst.name] = []
210
+ name_conflicts[inst.name].append(inst)
211
+
212
+ # Handle conflicts
213
+ install_names = {} # Map instruction ID to final install name
214
+ for name, insts in name_conflicts.items():
215
+ if len(insts) > 1:
216
+ console.print(f"\n[yellow]⚠️ Name Conflict:[/yellow] " f"{len(insts)} instructions named '{name}'")
217
+ console.print("\nHow should they be installed?")
218
+ console.print(" [1] Namespace by repository (recommended)")
219
+ for inst in insts:
220
+ console.print(f" → {inst.repo_namespace}/{name}")
221
+ console.print(" [2] Skip installation (cancel)")
222
+
223
+ choice = typer.prompt("Select (1-2)", default="1")
224
+
225
+ if choice == "1":
226
+ # Use namespaced names
227
+ for inst in insts:
228
+ install_names[inst.id] = f"{inst.repo_namespace}/{name}"
229
+ else:
230
+ print_info("Installation cancelled")
231
+ return None
232
+ else:
233
+ # No conflict, use simple name
234
+ install_names[insts[0].id] = name
235
+
236
+ return install_names
237
+
238
+
239
+ def _get_ai_tools_from_names(tool_names: list[str], detector: AIToolDetector) -> Optional[list[AITool]]:
240
+ """Get AI tool instances from tool names.
241
+
242
+ Args:
243
+ tool_names: List of tool name strings
244
+ detector: AI tool detector instance
245
+
246
+ Returns:
247
+ List of AITool instances or None if any invalid/not installed
248
+ """
249
+ ai_tools = []
250
+ for tool_name in tool_names:
251
+ tool = detector.get_tool_by_name(tool_name)
252
+ if not tool:
253
+ print_error(f"AI tool not found: {tool_name}")
254
+ return None
255
+ if not tool.is_installed():
256
+ print_error(f"{tool.tool_name} is not installed")
257
+ return None
258
+ ai_tools.append(tool)
259
+ return ai_tools
260
+
261
+
262
+ def _show_installation_preview(
263
+ project_root: Path, instructions: list, ai_tools: list, install_names: dict[str, str]
264
+ ) -> bool:
265
+ """Show installation preview and ask for confirmation.
266
+
267
+ Args:
268
+ project_root: Project root path
269
+ instructions: List of instructions to install
270
+ ai_tools: List of AI tools to install to
271
+ install_names: Mapping of instruction IDs to install names
272
+
273
+ Returns:
274
+ True if user confirms, False otherwise
275
+ """
276
+ console.print("\n[bold cyan]📦 Installation Preview[/bold cyan]")
277
+ console.print(f"\n[bold]Project:[/bold] {project_root}")
278
+ console.print(f"[bold]Instructions:[/bold] {len(instructions)} selected")
279
+ console.print(f"[bold]Target tools:[/bold] {', '.join([t.tool_name for t in ai_tools])}\n")
280
+
281
+ # Show where files will be created
282
+ console.print("[bold yellow]The following files will be created:[/bold yellow]\n")
283
+
284
+ for ai_tool in ai_tools:
285
+ tool_dir = ai_tool.get_project_instructions_directory(project_root)
286
+ console.print(f"[cyan]{ai_tool.tool_name}[/cyan] → {tool_dir}")
287
+ for inst in instructions:
288
+ install_name = install_names[inst.id]
289
+ filename = f"{install_name}{ai_tool.get_instruction_file_extension()}"
290
+ console.print(f" • {filename}")
291
+ console.print()
292
+
293
+ # Ask for confirmation
294
+ return Confirm.ask("\n[bold]Proceed with installation?[/bold]", default=True)
295
+
296
+
297
+ def _check_for_upgrades(
298
+ instructions: list,
299
+ ai_tools: list,
300
+ install_names: dict[str, str],
301
+ project_root: Optional[Path],
302
+ ) -> dict[str, tuple[InstallationRecord, LibraryInstruction]]:
303
+ """Check if any instructions being installed are upgrades of existing installations.
304
+
305
+ Args:
306
+ instructions: List of instructions to install
307
+ ai_tools: List of AI tools to install to
308
+ install_names: Mapping of instruction IDs to install names
309
+ project_root: Project root path
310
+
311
+ Returns:
312
+ Dictionary mapping instruction_id to (existing_record, new_instruction) for upgrades
313
+ """
314
+ tracker = InstallationTracker()
315
+ upgrades = {}
316
+
317
+ for ai_tool in ai_tools:
318
+ for inst in instructions:
319
+ install_name = install_names[inst.id]
320
+
321
+ # Check if this instruction is already installed for this tool
322
+ existing = tracker.get_installation(install_name, ai_tool.tool_type, project_root)
323
+
324
+ if existing:
325
+ # Extract ref from both old and new
326
+ old_ref = existing.source_ref
327
+ new_ref, new_ref_type = _extract_ref_from_namespace(inst.repo_namespace)
328
+
329
+ # Check if versions differ (potential upgrade)
330
+ if old_ref and new_ref and old_ref != new_ref:
331
+ key = f"{inst.id}_{ai_tool.tool_type.value}"
332
+ upgrades[key] = (existing, inst)
333
+
334
+ return upgrades
335
+
336
+
337
+ def _prompt_for_upgrade(
338
+ existing: InstallationRecord,
339
+ new_instruction: LibraryInstruction,
340
+ ) -> bool:
341
+ """Prompt user to confirm upgrade from one version to another.
342
+
343
+ Args:
344
+ existing: Existing installation record
345
+ new_instruction: New instruction being installed
346
+
347
+ Returns:
348
+ True if user confirms upgrade, False otherwise
349
+ """
350
+ old_ref = existing.source_ref or "unknown"
351
+ new_ref, _ = _extract_ref_from_namespace(new_instruction.repo_namespace)
352
+ new_ref = new_ref or "unknown"
353
+
354
+ console.print(f"\n[yellow]⚠️ Upgrade Detected:[/yellow] {existing.instruction_name}")
355
+ console.print(f" Current version: [cyan]{old_ref}[/cyan]")
356
+ console.print(f" New version: [green]{new_ref}[/green]")
357
+ console.print(f" Tool: {existing.ai_tool.value}")
358
+
359
+ return Confirm.ask("\n[bold]Upgrade to new version?[/bold]", default=True)
360
+
361
+
362
+ def _perform_installation(
363
+ instructions: list,
364
+ ai_tools: list,
365
+ install_names: dict[str, str],
366
+ install_scope: InstallationScope,
367
+ project_root: Optional[Path],
368
+ strategy: ConflictResolution,
369
+ ) -> tuple[int, int]:
370
+ """Perform the actual installation of instructions to tools.
371
+
372
+ Args:
373
+ instructions: List of instructions to install
374
+ ai_tools: List of AI tools to install to
375
+ install_names: Mapping of instruction IDs to install names
376
+ install_scope: Installation scope (project/global)
377
+ project_root: Project root path
378
+ strategy: Conflict resolution strategy
379
+
380
+ Returns:
381
+ Tuple of (installed_count, skipped_count)
382
+ """
383
+ tracker = InstallationTracker()
384
+ resolver = ConflictResolver(default_strategy=strategy)
385
+
386
+ installed_count = 0
387
+ skipped_count = 0
388
+
389
+ for ai_tool in ai_tools:
390
+ console.print(f"\nInstalling to [cyan]{ai_tool.tool_name}[/cyan]...")
391
+
392
+ for inst in instructions:
393
+ install_name = install_names[inst.id]
394
+
395
+ # Get target path
396
+ target_path = ai_tool.get_instruction_path(install_name, install_scope, project_root)
397
+
398
+ # Handle existing files
399
+ if target_path.exists():
400
+ # Determine the actual strategy to use
401
+ actual_strategy = strategy
402
+
403
+ # If strategy is PROMPT, ask user interactively
404
+ if strategy == ConflictResolution.PROMPT:
405
+ actual_strategy = prompt_conflict_resolution(install_name)
406
+
407
+ if actual_strategy == ConflictResolution.SKIP:
408
+ console.print(f" [yellow]Skipped:[/yellow] {install_name} (already exists)")
409
+ skipped_count += 1
410
+ continue
411
+ elif actual_strategy == ConflictResolution.RENAME:
412
+ conflict_info = resolver.resolve(install_name, target_path, actual_strategy)
413
+ if conflict_info.new_path is None:
414
+ console.print(f" [red]Error:[/red] Failed to rename {install_name}")
415
+ continue
416
+ target_path = Path(conflict_info.new_path)
417
+ console.print(f" [yellow]Renamed:[/yellow] {install_name} -> {target_path.name}")
418
+ elif actual_strategy == ConflictResolution.OVERWRITE:
419
+ console.print(f" [yellow]Overwriting:[/yellow] {install_name}")
420
+
421
+ # Copy file from library
422
+ try:
423
+ target_path.parent.mkdir(parents=True, exist_ok=True)
424
+
425
+ # Read from library
426
+ source_path = Path(inst.file_path)
427
+ content = source_path.read_text(encoding="utf-8")
428
+
429
+ # Write to target
430
+ target_path.write_text(content, encoding="utf-8")
431
+
432
+ # Extract ref information from namespace
433
+ source_ref, source_ref_type = _extract_ref_from_namespace(inst.repo_namespace)
434
+
435
+ # Track installation
436
+ record = InstallationRecord(
437
+ instruction_name=install_name,
438
+ ai_tool=ai_tool.tool_type,
439
+ source_repo=inst.repo_url,
440
+ installed_path=str(target_path),
441
+ installed_at=datetime.now(),
442
+ checksum=inst.checksum,
443
+ bundle_name=None,
444
+ scope=install_scope,
445
+ source_ref=source_ref,
446
+ source_ref_type=source_ref_type,
447
+ )
448
+ tracker.add_installation(record, project_root)
449
+
450
+ console.print(f" [green]✓[/green] Installed: {install_name}")
451
+ installed_count += 1
452
+
453
+ except Exception as e:
454
+ print_error(f"Failed to install {install_name}: {e}")
455
+
456
+ return installed_count, skipped_count
457
+
458
+
459
+ # ============================================================================
460
+ # Public Installation Functions
461
+ # ============================================================================
462
+
463
+
464
+ def install_from_library_tui(
465
+ tool: Optional[str] = None,
466
+ ) -> int:
467
+ """
468
+ Show TUI to select and install instructions from library.
469
+
470
+ Args:
471
+ tool: AI tool to install to (None = auto-detect)
472
+
473
+ Returns:
474
+ Exit code
475
+ """
476
+ library = LibraryManager()
477
+
478
+ # Check if library is empty
479
+ if not library.list_instructions():
480
+ print_info("Library is empty. Use 'instructionkit download --repo <url>' to add instructions.")
481
+ return 1
482
+
483
+ # Show TUI (always installs to project level)
484
+ result = show_installer_tui(library=library, tool=tool)
485
+
486
+ if not result:
487
+ console.print("[dim]Cancelled[/dim]")
488
+ return 0
489
+
490
+ # Install selected instructions
491
+ selected_instructions = result["instructions"]
492
+ selected_tools = result["tools"] # Now a list of tool names
493
+
494
+ return install_from_library_direct_multi_tool(
495
+ instruction_ids=[inst.id for inst in selected_instructions],
496
+ tools=selected_tools,
497
+ conflict_strategy="skip",
498
+ )
499
+
500
+
501
+ def install_from_library_direct_multi_tool(
502
+ instruction_ids: list[str],
503
+ tools: list[str],
504
+ conflict_strategy: str = "skip",
505
+ ) -> int:
506
+ """
507
+ Install specific instructions from library by ID to multiple tools.
508
+
509
+ Args:
510
+ instruction_ids: List of instruction IDs to install
511
+ tools: List of AI tool names to install to
512
+ conflict_strategy: Conflict resolution strategy
513
+
514
+ Returns:
515
+ Exit code
516
+ """
517
+ library = LibraryManager()
518
+ install_scope = InstallationScope.PROJECT
519
+
520
+ # Parse conflict strategy
521
+ strategy = _parse_conflict_strategy(conflict_strategy)
522
+ if strategy is None:
523
+ return 1
524
+
525
+ # Get project root
526
+ project_root = _get_project_root_for_installation()
527
+ if project_root is None:
528
+ return 1
529
+
530
+ # Load instructions from library
531
+ instructions = _load_instructions_from_library(instruction_ids, library)
532
+ if instructions is None:
533
+ return 1
534
+
535
+ # Resolve name conflicts
536
+ install_names = _resolve_name_conflicts(instructions)
537
+ if install_names is None:
538
+ return 0
539
+
540
+ # Get AI tools
541
+ detector = get_detector()
542
+ ai_tools = _get_ai_tools_from_names(tools, detector)
543
+ if ai_tools is None:
544
+ return 1
545
+
546
+ # Detect collisions with installed instructions from different repositories
547
+ collisions = _detect_installed_collisions(instructions, ai_tools, install_names, project_root)
548
+ if collisions:
549
+ console.print("\n[bold cyan]Handling Name Collisions[/bold cyan]")
550
+ for inst in instructions:
551
+ if inst.id in collisions:
552
+ current_name = install_names[inst.id]
553
+ custom_name = _prompt_for_custom_filename(inst, collisions[inst.id], current_name)
554
+ if custom_name is None:
555
+ # User chose to skip
556
+ console.print(f"[dim]Skipping {current_name}[/dim]")
557
+ # Remove from installation list
558
+ instructions = [i for i in instructions if i.id != inst.id]
559
+ else:
560
+ # Use custom name
561
+ install_names[inst.id] = custom_name
562
+
563
+ if not instructions:
564
+ console.print("[yellow]No instructions remaining to install[/yellow]")
565
+ return 0
566
+
567
+ # Show preview and confirm
568
+ if not _show_installation_preview(project_root, instructions, ai_tools, install_names):
569
+ console.print("[dim]Installation cancelled[/dim]")
570
+ return 0
571
+
572
+ # Check for upgrades and prompt if needed
573
+ upgrades = _check_for_upgrades(instructions, ai_tools, install_names, project_root)
574
+ if upgrades:
575
+ console.print("\n[bold cyan]Upgrade Confirmation[/bold cyan]")
576
+ for key, (existing, new_inst) in upgrades.items():
577
+ if not _prompt_for_upgrade(existing, new_inst):
578
+ console.print("[dim]Installation cancelled[/dim]")
579
+ return 0
580
+
581
+ # Perform installation
582
+ installed_count, skipped_count = _perform_installation(
583
+ instructions, ai_tools, install_names, install_scope, project_root, strategy
584
+ )
585
+
586
+ # Summary
587
+ console.print()
588
+ if installed_count > 0:
589
+ print_success(f"✓ Successfully installed {installed_count} instruction(s)")
590
+ if skipped_count > 0:
591
+ print_info(f"Skipped {skipped_count} existing instruction(s)")
592
+
593
+ return 0
594
+
595
+
596
+ def install_from_library_direct(
597
+ instruction_ids: list[str],
598
+ tool: Optional[str] = None,
599
+ conflict_strategy: str = "skip",
600
+ ) -> int:
601
+ """
602
+ Install specific instructions from library by ID.
603
+
604
+ Args:
605
+ instruction_ids: List of instruction IDs to install
606
+ tool: AI tool to install to
607
+ conflict_strategy: Conflict resolution strategy
608
+
609
+ Returns:
610
+ Exit code
611
+ """
612
+ library = LibraryManager()
613
+ install_scope = InstallationScope.PROJECT
614
+
615
+ # Parse conflict strategy
616
+ strategy = _parse_conflict_strategy(conflict_strategy)
617
+ if strategy is None:
618
+ return 1
619
+
620
+ # Get project root
621
+ project_root = _get_project_root_for_installation()
622
+ if project_root is None:
623
+ return 1
624
+
625
+ # Load instructions from library
626
+ instructions = _load_instructions_from_library(instruction_ids, library)
627
+ if instructions is None:
628
+ return 1
629
+
630
+ # Resolve name conflicts
631
+ install_names = _resolve_name_conflicts(instructions)
632
+ if install_names is None:
633
+ return 0
634
+
635
+ # Determine AI tool(s)
636
+ detector = get_detector()
637
+ if tool:
638
+ ai_tools = _get_ai_tools_from_names([tool], detector)
639
+ if ai_tools is None:
640
+ return 1
641
+ else:
642
+ ai_tools = detector.detect_installed_tools()
643
+ if not ai_tools:
644
+ print_error("No AI coding tools detected")
645
+ return 1
646
+
647
+ # Detect collisions with installed instructions from different repositories
648
+ collisions = _detect_installed_collisions(instructions, ai_tools, install_names, project_root)
649
+ if collisions:
650
+ console.print("\n[bold cyan]Handling Name Collisions[/bold cyan]")
651
+ for inst in instructions:
652
+ if inst.id in collisions:
653
+ current_name = install_names[inst.id]
654
+ custom_name = _prompt_for_custom_filename(inst, collisions[inst.id], current_name)
655
+ if custom_name is None:
656
+ # User chose to skip
657
+ console.print(f"[dim]Skipping {current_name}[/dim]")
658
+ # Remove from installation list
659
+ instructions = [i for i in instructions if i.id != inst.id]
660
+ else:
661
+ # Use custom name
662
+ install_names[inst.id] = custom_name
663
+
664
+ if not instructions:
665
+ console.print("[yellow]No instructions remaining to install[/yellow]")
666
+ return 0
667
+
668
+ # Show preview and confirm
669
+ if not _show_installation_preview(project_root, instructions, ai_tools, install_names):
670
+ console.print("[dim]Installation cancelled[/dim]")
671
+ return 0
672
+
673
+ # Check for upgrades and prompt if needed
674
+ upgrades = _check_for_upgrades(instructions, ai_tools, install_names, project_root)
675
+ if upgrades:
676
+ console.print("\n[bold cyan]Upgrade Confirmation[/bold cyan]")
677
+ for key, (existing, new_inst) in upgrades.items():
678
+ if not _prompt_for_upgrade(existing, new_inst):
679
+ console.print("[dim]Installation cancelled[/dim]")
680
+ return 0
681
+
682
+ # Perform installation
683
+ installed_count, skipped_count = _perform_installation(
684
+ instructions, ai_tools, install_names, install_scope, project_root, strategy
685
+ )
686
+
687
+ # Summary
688
+ console.print()
689
+ if installed_count > 0:
690
+ print_success(f"✓ Successfully installed {installed_count} instruction(s)")
691
+ if skipped_count > 0:
692
+ print_info(f"Skipped {skipped_count} existing instruction(s)")
693
+
694
+ return 0
695
+
696
+
697
+ def install_from_library_by_name(
698
+ name: str,
699
+ tool: Optional[str] = None,
700
+ conflict_strategy: str = "skip",
701
+ ) -> int:
702
+ """
703
+ Install instruction(s) from library by name.
704
+
705
+ Supports source/name format for disambiguation (e.g., 'company/python-best-practices').
706
+
707
+ Args:
708
+ name: Instruction name (or source/name format)
709
+ tool: AI tool to install to
710
+ conflict_strategy: Conflict resolution strategy
711
+
712
+ Returns:
713
+ Exit code
714
+ """
715
+ library = LibraryManager()
716
+
717
+ # Parse source/name format
718
+ source_alias = None
719
+ instruction_name = name
720
+ if "/" in name:
721
+ parts = name.split("/", 1)
722
+ source_alias = parts[0]
723
+ instruction_name = parts[1]
724
+
725
+ # Find instructions with this name
726
+ if source_alias:
727
+ # Filter by source alias
728
+ instructions = library.get_instructions_by_source_and_name(source_alias, instruction_name)
729
+ else:
730
+ instructions = library.get_instructions_by_name(instruction_name)
731
+
732
+ if not instructions:
733
+ print_error(f"No instruction named '{name}' found in library.")
734
+ print_info("Use 'instructionkit list library --instructions' to see available instructions.")
735
+ return 1
736
+
737
+ if len(instructions) == 1:
738
+ # Single match, install it
739
+ return install_from_library_direct(
740
+ instruction_ids=[instructions[0].id],
741
+ tool=tool,
742
+ conflict_strategy=conflict_strategy,
743
+ )
744
+
745
+ # Multiple matches - show options
746
+ console.print(f"\n[yellow]Multiple instructions named '{name}' found:[/yellow]\n")
747
+ for i, inst in enumerate(instructions, 1):
748
+ # Extract ref from namespace for display
749
+ ref, ref_type = _extract_ref_from_namespace(inst.repo_namespace)
750
+ ref_display = f"@{ref}" if ref else f"v{inst.version}"
751
+ console.print(f" [{i}] {inst.repo_name} ({ref_display}) - {inst.author}")
752
+ console.print(f" {inst.description}")
753
+ console.print()
754
+
755
+ console.print(" [A] Install all")
756
+ console.print(" [C] Cancel")
757
+ console.print()
758
+
759
+ choice = typer.prompt("Select", default="C")
760
+
761
+ if choice.upper() == "C":
762
+ print_info("Installation cancelled")
763
+ return 0
764
+ elif choice.upper() == "A":
765
+ # Install all
766
+ return install_from_library_direct(
767
+ instruction_ids=[inst.id for inst in instructions],
768
+ tool=tool,
769
+ conflict_strategy=conflict_strategy,
770
+ )
771
+ elif choice.isdigit():
772
+ idx = int(choice) - 1
773
+ if 0 <= idx < len(instructions):
774
+ return install_from_library_direct(
775
+ instruction_ids=[instructions[idx].id],
776
+ tool=tool,
777
+ conflict_strategy=conflict_strategy,
778
+ )
779
+
780
+ print_error("Invalid selection")
781
+ return 1
782
+
783
+
784
+ # Keep the original direct install function for backward compatibility
785
+ def install_from_repo_direct(
786
+ name: str,
787
+ repo: str,
788
+ tool: Optional[str] = None,
789
+ conflict_strategy: str = "skip",
790
+ bundle: bool = False,
791
+ ) -> int:
792
+ """
793
+ Install directly from a repository (backward compatibility).
794
+
795
+ This is the original install function, preserved for --repo usage.
796
+ """
797
+ # Import the original function
798
+ from aiconfigkit.cli.install import install_instruction as original_install
799
+
800
+ return original_install(
801
+ name=name,
802
+ repo=repo,
803
+ tool=tool,
804
+ conflict_strategy=conflict_strategy,
805
+ bundle=bundle,
806
+ )
807
+
808
+
809
+ def install_multiple_from_library(
810
+ names: list[str],
811
+ tools: Optional[list[str]],
812
+ conflict_strategy: str,
813
+ ) -> int:
814
+ """
815
+ Install multiple instructions from library.
816
+
817
+ Args:
818
+ names: List of instruction names
819
+ tools: List of AI tool names (None = all detected tools)
820
+ conflict_strategy: Conflict resolution strategy
821
+
822
+ Returns:
823
+ Exit code
824
+ """
825
+ library = LibraryManager()
826
+
827
+ # Get all instructions by name
828
+ all_instructions = []
829
+ for name in names:
830
+ insts = library.get_instructions_by_name(name)
831
+ if not insts:
832
+ print_error(f"No instruction named '{name}' found in library.")
833
+ return 1
834
+
835
+ if len(insts) > 1:
836
+ # Multiple matches - show options
837
+ console.print(f"\n[yellow]Multiple instructions named '{name}' found:[/yellow]\n")
838
+ for i, inst in enumerate(insts, 1):
839
+ console.print(f" [{i}] {inst.repo_name} (v{inst.version}) - {inst.author}")
840
+ console.print()
841
+
842
+ choice = typer.prompt(f"Select which '{name}' to install (1-{len(insts)})", default="1")
843
+
844
+ if choice.isdigit():
845
+ idx = int(choice) - 1
846
+ if 0 <= idx < len(insts):
847
+ all_instructions.append(insts[idx])
848
+ else:
849
+ print_error("Invalid selection")
850
+ return 1
851
+ else:
852
+ print_error("Invalid selection")
853
+ return 1
854
+ else:
855
+ all_instructions.append(insts[0])
856
+
857
+ # Get instruction IDs
858
+ instruction_ids = [inst.id for inst in all_instructions]
859
+
860
+ # Install using the multi-tool function
861
+ if tools:
862
+ return install_from_library_direct_multi_tool(
863
+ instruction_ids=instruction_ids,
864
+ tools=tools,
865
+ conflict_strategy=conflict_strategy,
866
+ )
867
+ else:
868
+ # Use existing single-tool logic (all tools)
869
+ return install_from_library_direct(
870
+ instruction_ids=instruction_ids,
871
+ tool=None, # Will install to all detected tools
872
+ conflict_strategy=conflict_strategy,
873
+ )
874
+
875
+
876
+ def install_instruction_unified(
877
+ names: Optional[list[str]] = None,
878
+ repo: Optional[str] = None,
879
+ tools: Optional[list[str]] = None,
880
+ conflict_strategy: str = "prompt",
881
+ bundle: bool = False,
882
+ ) -> int:
883
+ """
884
+ Unified install function that routes to appropriate implementation.
885
+
886
+ All installations are at project level.
887
+
888
+ Args:
889
+ names: Instruction name(s) (optional, can be multiple)
890
+ repo: Repository URL (optional)
891
+ tools: AI tool(s) to install to (optional, can be multiple)
892
+ conflict_strategy: Conflict resolution strategy
893
+ bundle: Whether installing a bundle
894
+
895
+ Returns:
896
+ Exit code
897
+ """
898
+ # Convert single tool format (backward compat)
899
+ tool = tools[0] if tools and len(tools) == 1 else None
900
+
901
+ # Case 1: Direct install from repo (backward compat)
902
+ if repo:
903
+ if not names or len(names) == 0:
904
+ print_error("When using --repo, you must specify an instruction name")
905
+ return 1
906
+
907
+ # Only support single name with --repo for now
908
+ if len(names) > 1:
909
+ print_error("Cannot install multiple instructions with --repo. Install one at a time or use the library.")
910
+ return 1
911
+
912
+ return install_from_repo_direct(
913
+ name=names[0],
914
+ repo=repo,
915
+ tool=tool,
916
+ conflict_strategy=conflict_strategy,
917
+ bundle=bundle,
918
+ )
919
+
920
+ # Case 2: Install from library with TUI
921
+ if not names or len(names) == 0:
922
+ return install_from_library_tui(tool=tool)
923
+
924
+ # Case 3: Install multiple instructions from library
925
+ if len(names) > 1 or (tools and len(tools) > 1):
926
+ return install_multiple_from_library(
927
+ names=names,
928
+ tools=tools,
929
+ conflict_strategy=conflict_strategy,
930
+ )
931
+
932
+ # Case 4: Install single instruction from library by name
933
+ return install_from_library_by_name(
934
+ name=names[0],
935
+ tool=tool,
936
+ conflict_strategy=conflict_strategy,
937
+ )