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,539 @@
1
+ """Git operations for cloning and managing instruction repositories."""
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ from contextlib import AbstractContextManager
8
+ from pathlib import Path
9
+ from typing import Any, Iterator, Optional
10
+
11
+ import git
12
+ from git import Repo
13
+ from git.exc import GitCommandError
14
+
15
+ from aiconfigkit.core.models import RefType
16
+ from aiconfigkit.utils.validation import is_valid_git_url
17
+
18
+
19
+ class GitOperationError(Exception):
20
+ """Raised when a Git operation fails."""
21
+
22
+ pass
23
+
24
+
25
+ class RepositoryOperationError(Exception):
26
+ """Custom exception for repository operations with detailed error information."""
27
+
28
+ def __init__(self, message: str, error_type: str, original_error: Optional[Exception] = None):
29
+ """
30
+ Initialize repository operation error.
31
+
32
+ Args:
33
+ message: Human-readable error message
34
+ error_type: Error category (e.g., 'network_error', 'invalid_reference')
35
+ original_error: Original exception that caused this error
36
+ """
37
+ super().__init__(message)
38
+ self.error_type = error_type
39
+ self.original_error = original_error
40
+
41
+
42
+ class GitOperations:
43
+ """Handle Git repository operations."""
44
+
45
+ @staticmethod
46
+ def is_local_path(repo_url: str) -> bool:
47
+ """
48
+ Check if the repository URL is a local file path.
49
+
50
+ Args:
51
+ repo_url: Repository URL or path
52
+
53
+ Returns:
54
+ True if it's a local path, False if it's a remote Git URL
55
+ """
56
+ # Remote URLs have protocols or SSH format
57
+ if repo_url.startswith(("https://", "http://", "git://", "ssh://")):
58
+ return False
59
+
60
+ # SSH format (git@host:path)
61
+ if "@" in repo_url and ":" in repo_url and not repo_url.startswith("/"):
62
+ return False
63
+
64
+ # Everything else is treated as a local path
65
+ return True
66
+
67
+ @staticmethod
68
+ def clone_repository(
69
+ repo_url: str, target_dir: Optional[Path] = None, branch: Optional[str] = None, depth: int = 1
70
+ ) -> Path:
71
+ """
72
+ Clone a Git repository or use a local directory.
73
+
74
+ Args:
75
+ repo_url: URL of Git repository to clone or path to local directory
76
+ target_dir: Directory to clone into (creates temp dir if None)
77
+ branch: Specific branch to clone (defaults to default branch)
78
+ depth: Clone depth (1 for shallow clone)
79
+
80
+ Returns:
81
+ Path to repository
82
+
83
+ Raises:
84
+ GitOperationError: If clone fails
85
+ ValueError: If repo_url is invalid
86
+ """
87
+ # Validate URL
88
+ if not is_valid_git_url(repo_url):
89
+ raise ValueError(f"Invalid Git repository URL: {repo_url}")
90
+
91
+ # Handle local directories
92
+ if GitOperations.is_local_path(repo_url):
93
+ local_path = Path(repo_url).resolve()
94
+ if not local_path.exists():
95
+ raise GitOperationError(f"Local directory does not exist: {local_path}")
96
+ if not local_path.is_dir():
97
+ raise GitOperationError(f"Path is not a directory: {local_path}")
98
+ return local_path
99
+
100
+ # Create target directory if not provided
101
+ if target_dir is None:
102
+ target_dir = Path(tempfile.mkdtemp(prefix="instructionkit-"))
103
+ else:
104
+ target_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ # Build git clone command
107
+ cmd = ["git", "clone"]
108
+
109
+ # Add depth for shallow clone
110
+ if depth > 0:
111
+ cmd.extend(["--depth", str(depth)])
112
+
113
+ # Add branch if specified
114
+ if branch:
115
+ cmd.extend(["--branch", branch])
116
+
117
+ # Add URL and target directory
118
+ cmd.extend([repo_url, str(target_dir)])
119
+
120
+ try:
121
+ # Execute git clone
122
+ subprocess.run(cmd, capture_output=True, text=True, check=True, timeout=300) # 5 minute timeout
123
+
124
+ return target_dir
125
+
126
+ except subprocess.CalledProcessError as e:
127
+ # Clean up target directory on failure
128
+ if target_dir.exists():
129
+ shutil.rmtree(target_dir, ignore_errors=True)
130
+
131
+ error_msg = e.stderr if e.stderr else str(e)
132
+ raise GitOperationError(f"Failed to clone repository: {error_msg}")
133
+
134
+ except subprocess.TimeoutExpired:
135
+ # Clean up on timeout
136
+ if target_dir.exists():
137
+ shutil.rmtree(target_dir, ignore_errors=True)
138
+
139
+ raise GitOperationError("Repository clone timed out after 5 minutes")
140
+
141
+ except Exception as e:
142
+ # Clean up on any other error
143
+ if target_dir.exists():
144
+ shutil.rmtree(target_dir, ignore_errors=True)
145
+
146
+ raise GitOperationError(f"Unexpected error during clone: {str(e)}")
147
+
148
+ @staticmethod
149
+ def is_git_installed() -> bool:
150
+ """
151
+ Check if Git is installed and accessible.
152
+
153
+ Returns:
154
+ True if git command is available
155
+ """
156
+ try:
157
+ result = subprocess.run(["git", "--version"], capture_output=True, timeout=5)
158
+ return result.returncode == 0
159
+ except (subprocess.SubprocessError, FileNotFoundError):
160
+ return False
161
+
162
+ @staticmethod
163
+ def get_git_version() -> Optional[str]:
164
+ """
165
+ Get installed Git version.
166
+
167
+ Returns:
168
+ Git version string or None if not installed
169
+ """
170
+ try:
171
+ result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5)
172
+ if result.returncode == 0:
173
+ # Output format: "git version 2.x.x"
174
+ return result.stdout.strip()
175
+ return None
176
+ except (subprocess.SubprocessError, FileNotFoundError):
177
+ return None
178
+
179
+ @staticmethod
180
+ def cleanup_repository(repo_path: Path, is_temp: bool = True) -> None:
181
+ """
182
+ Clean up a cloned repository directory.
183
+
184
+ Args:
185
+ repo_path: Path to repository to clean up
186
+ is_temp: Whether this is a temporary directory (safe to delete)
187
+ """
188
+ # Only delete if it's a temporary directory
189
+ if is_temp and repo_path.exists() and repo_path.is_dir():
190
+ # Safety check: only delete if it's in temp directory
191
+ if "instructionkit-" in str(repo_path) or "/tmp/" in str(repo_path):
192
+ shutil.rmtree(repo_path, ignore_errors=True)
193
+
194
+ @staticmethod
195
+ def detect_ref_type(repo_url: str, ref: Optional[str]) -> tuple[Optional[str], RefType]:
196
+ """
197
+ Detect and validate reference type for a remote repository.
198
+
199
+ Args:
200
+ repo_url: Git repository URL
201
+ ref: Reference to check (None = default branch)
202
+
203
+ Returns:
204
+ Tuple of (validated_ref, ref_type)
205
+
206
+ Raises:
207
+ RepositoryOperationError: If reference validation fails
208
+ """
209
+ if ref is None:
210
+ # Use default branch
211
+ return (None, RefType.BRANCH)
212
+
213
+ # Check if it looks like a commit hash (40-char hex or 7+ char hex)
214
+ if re.match(r"^[0-9a-f]{7,40}$", ref.lower()):
215
+ return (ref, RefType.COMMIT)
216
+
217
+ # Check remote refs via ls-remote
218
+ g = git.cmd.Git()
219
+ try:
220
+ remote_refs = {}
221
+ for line in g.ls_remote(repo_url).split("\n"):
222
+ if line and "\t" in line:
223
+ hash_ref = line.split("\t")
224
+ remote_refs[hash_ref[1]] = hash_ref[0]
225
+
226
+ # Priority: tags > branches (Git's default behavior)
227
+ tag_ref = f"refs/tags/{ref}"
228
+ if tag_ref in remote_refs:
229
+ return (ref, RefType.TAG)
230
+
231
+ branch_ref = f"refs/heads/{ref}"
232
+ if branch_ref in remote_refs:
233
+ return (ref, RefType.BRANCH)
234
+
235
+ raise RepositoryOperationError(f"Reference '{ref}' not found in repository", error_type="invalid_reference")
236
+
237
+ except GitCommandError as e:
238
+ raise RepositoryOperationError(
239
+ f"Failed to access repository: {e}", error_type="network_error", original_error=e
240
+ )
241
+
242
+ @staticmethod
243
+ def validate_remote_ref(repo_url: str, ref: str, ref_type: RefType) -> bool:
244
+ """
245
+ Validate that a specific reference exists on remote repository.
246
+
247
+ Args:
248
+ repo_url: Git repository URL
249
+ ref: Reference name
250
+ ref_type: Expected reference type ('tag' or 'branch')
251
+
252
+ Returns:
253
+ True if reference exists, False otherwise
254
+
255
+ Raises:
256
+ RepositoryOperationError: If validation fails due to network/access issues
257
+ """
258
+ g = git.cmd.Git()
259
+
260
+ try:
261
+ if ref_type == RefType.BRANCH:
262
+ # Check for branch (heads)
263
+ g.ls_remote("--exit-code", "--heads", repo_url, ref)
264
+ elif ref_type == RefType.TAG:
265
+ # Check for tag
266
+ g.ls_remote("--exit-code", "--tags", repo_url, ref)
267
+ elif ref_type == RefType.COMMIT:
268
+ # Commits can't be validated via ls-remote
269
+ # Will be validated during fetch/checkout
270
+ return True
271
+ return True
272
+ except GitCommandError as e:
273
+ # exit-code 2 means reference not found
274
+ if e.status == 2:
275
+ return False
276
+ # Other errors are network/access issues
277
+ raise RepositoryOperationError(
278
+ f"Failed to validate reference: {e}", error_type="network_error", original_error=e
279
+ )
280
+
281
+ @staticmethod
282
+ def clone_at_ref(
283
+ repo_url: str, destination: Path, ref: Optional[str] = None, ref_type: Optional[RefType] = None, depth: int = 1
284
+ ) -> Repo:
285
+ """
286
+ Clone repository at a specific reference.
287
+
288
+ Args:
289
+ repo_url: Git repository URL
290
+ destination: Local directory to clone into
291
+ ref: Reference to clone (tag, branch, or commit hash)
292
+ ref_type: Type of reference (if known)
293
+ depth: Clone depth (1 for shallow, 0 for full)
294
+
295
+ Returns:
296
+ GitPython Repo object
297
+
298
+ Raises:
299
+ RepositoryOperationError: If clone fails
300
+ """
301
+ # Create destination directory
302
+ destination.mkdir(parents=True, exist_ok=True)
303
+
304
+ # Handle default branch (no ref specified)
305
+ if ref is None:
306
+ try:
307
+ return Repo.clone_from(url=repo_url, to_path=str(destination), depth=depth if depth > 0 else None)
308
+ except GitCommandError as e:
309
+ raise RepositoryOperationError(
310
+ f"Failed to clone repository: {e.stderr}", error_type="git_command_error", original_error=e
311
+ )
312
+
313
+ # Handle tags and branches (can use 'branch' parameter)
314
+ if ref_type in (RefType.TAG, RefType.BRANCH, None):
315
+ try:
316
+ return Repo.clone_from(
317
+ url=repo_url,
318
+ to_path=str(destination),
319
+ branch=ref,
320
+ single_branch=True,
321
+ depth=depth if depth > 0 else None,
322
+ )
323
+ except GitCommandError as e:
324
+ # If branch parameter fails and we haven't confirmed it's a commit, raise error
325
+ if ref_type != RefType.COMMIT:
326
+ stderr = e.stderr.lower() if e.stderr else ""
327
+ if "remote branch" in stderr and "not found" in stderr:
328
+ raise RepositoryOperationError(
329
+ f"Reference '{ref}' not found in repository",
330
+ error_type="invalid_reference",
331
+ original_error=e,
332
+ )
333
+ raise RepositoryOperationError(
334
+ f"Failed to clone at ref '{ref}': {e.stderr}",
335
+ error_type="git_command_error",
336
+ original_error=e,
337
+ )
338
+
339
+ # Handle commits (requires two-step process)
340
+ if ref_type == RefType.COMMIT:
341
+ try:
342
+ # Step 1: Shallow clone default branch
343
+ repo = Repo.clone_from(url=repo_url, to_path=str(destination), depth=depth if depth > 0 else None)
344
+
345
+ # Step 2: Fetch and checkout specific commit
346
+ try:
347
+ # Try shallow fetch first (requires server support)
348
+ repo.git.fetch("origin", ref, depth=1)
349
+ repo.git.checkout(ref)
350
+ except GitCommandError:
351
+ # Fall back to full fetch if shallow fails
352
+ repo.git.fetch("origin", ref)
353
+ repo.git.checkout(ref)
354
+
355
+ return repo
356
+
357
+ except GitCommandError as e:
358
+ raise RepositoryOperationError(
359
+ f"Failed to clone at commit '{ref}': {e.stderr}",
360
+ error_type="git_command_error",
361
+ original_error=e,
362
+ )
363
+
364
+ raise RepositoryOperationError(f"Unable to clone at ref '{ref}'", error_type="unknown_error")
365
+
366
+ @staticmethod
367
+ def get_repo_info(repo: Repo) -> dict[str, Any]:
368
+ """
369
+ Extract useful repository information.
370
+
371
+ Args:
372
+ repo: GitPython Repo object
373
+
374
+ Returns:
375
+ Dictionary with repository metadata
376
+ """
377
+ try:
378
+ current_branch = repo.active_branch.name if not repo.head.is_detached else None
379
+ except Exception:
380
+ current_branch = None
381
+
382
+ return {
383
+ "url": repo.remotes.origin.url if repo.remotes else None,
384
+ "current_branch": current_branch,
385
+ "current_commit": repo.head.commit.hexsha,
386
+ "is_dirty": repo.is_dirty(),
387
+ "is_shallow": repo.git.rev_parse("--is-shallow-repository") == "true",
388
+ "tags": [tag.name for tag in repo.tags],
389
+ "branches": [branch.name for branch in repo.heads],
390
+ }
391
+
392
+ @staticmethod
393
+ def check_for_updates(repo: Repo, branch: str = "main") -> bool:
394
+ """
395
+ Check if remote branch has new commits without pulling.
396
+
397
+ Args:
398
+ repo: GitPython Repo object
399
+ branch: Branch name to check
400
+
401
+ Returns:
402
+ True if remote has updates, False otherwise
403
+
404
+ Raises:
405
+ RepositoryOperationError: If check fails
406
+ """
407
+ try:
408
+ # Get current local commit
409
+ local_commit = repo.head.commit.hexsha
410
+
411
+ # Fetch remote refs (doesn't modify working tree)
412
+ origin = repo.remotes.origin
413
+ origin.fetch()
414
+
415
+ # Get remote commit
416
+ remote_commit = origin.refs[branch].commit.hexsha
417
+
418
+ # Compare
419
+ return local_commit != remote_commit
420
+ except Exception as e:
421
+ raise RepositoryOperationError(
422
+ f"Failed to check for updates: {e}", error_type="network_error", original_error=e
423
+ )
424
+
425
+ @staticmethod
426
+ def pull_repository_updates(repo: Repo, branch: str = "main") -> dict[str, Any]:
427
+ """
428
+ Pull updates with conflict detection.
429
+
430
+ Args:
431
+ repo: GitPython Repo object
432
+ branch: Branch to pull
433
+
434
+ Returns:
435
+ Dictionary with pull result details
436
+
437
+ Raises:
438
+ RepositoryOperationError: If pull fails
439
+ """
440
+ try:
441
+ origin = repo.remotes.origin
442
+
443
+ # Check for local modifications
444
+ if repo.is_dirty():
445
+ return {
446
+ "success": False,
447
+ "error": "local_modifications",
448
+ "message": "Working directory has uncommitted changes",
449
+ }
450
+
451
+ # Pull
452
+ pull_info = origin.pull(branch)
453
+
454
+ # Check if any files were updated
455
+ updated_files = []
456
+ for info in pull_info:
457
+ if info.flags & info.HEAD_UPTODATE:
458
+ # Already up to date
459
+ continue
460
+ updated_files.append(str(info.ref))
461
+
462
+ return {"success": True, "updated": len(updated_files) > 0, "files": updated_files}
463
+
464
+ except GitCommandError as e:
465
+ # Pull failed - likely due to conflicts
466
+ stderr = e.stderr if e.stderr else ""
467
+ if "CONFLICT" in stderr:
468
+ return {"success": False, "error": "conflict", "message": str(e.stderr)}
469
+ else:
470
+ return {"success": False, "error": "unknown", "message": str(e)}
471
+
472
+ @staticmethod
473
+ def update_if_mutable(repo_path: Path, ref: str, ref_type: RefType) -> bool:
474
+ """
475
+ Update repository only if reference is mutable (branch).
476
+
477
+ Args:
478
+ repo_path: Path to repository
479
+ ref: Reference name
480
+ ref_type: Type of reference
481
+
482
+ Returns:
483
+ True if updated, False if skipped (immutable ref)
484
+
485
+ Raises:
486
+ RepositoryOperationError: If update fails
487
+ """
488
+ # Only update branches (mutable refs)
489
+ if ref_type != RefType.BRANCH:
490
+ return False
491
+
492
+ try:
493
+ repo = Repo(repo_path)
494
+
495
+ # Check if updates available
496
+ if not GitOperations.check_for_updates(repo, ref):
497
+ return False
498
+
499
+ # Pull updates
500
+ result = GitOperations.pull_repository_updates(repo, ref)
501
+
502
+ return bool(result.get("success", False) and result.get("updated", False))
503
+ except Exception as e:
504
+ raise RepositoryOperationError(
505
+ f"Failed to update repository: {e}", error_type="git_error", original_error=e
506
+ )
507
+
508
+
509
+ def with_temporary_clone(repo_url: str, branch: Optional[str] = None) -> AbstractContextManager[Path]:
510
+ """
511
+ Context manager for temporary repository clones.
512
+
513
+ Usage:
514
+ with with_temporary_clone(repo_url) as repo_path:
515
+ # Use repo_path
516
+ pass
517
+ # Repository is automatically cleaned up
518
+
519
+ Args:
520
+ repo_url: URL of repository to clone
521
+ branch: Optional branch to clone
522
+
523
+ Yields:
524
+ Path to cloned repository
525
+ """
526
+ from contextlib import contextmanager
527
+
528
+ @contextmanager
529
+ def _clone_context() -> Iterator[Path]:
530
+ repo_path = None
531
+ try:
532
+ git_ops = GitOperations()
533
+ repo_path = git_ops.clone_repository(repo_url, branch=branch)
534
+ yield repo_path
535
+ finally:
536
+ if repo_path and repo_path.exists():
537
+ GitOperations.cleanup_repository(repo_path)
538
+
539
+ return _clone_context()
@@ -0,0 +1 @@
1
+ """MCP server configuration management."""