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,169 @@
1
+ """Backup utilities for safe file operations."""
2
+
3
+ import shutil
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ def create_backup(file_path: Path, backup_dir: Optional[Path] = None) -> Path:
10
+ """
11
+ Create a timestamped backup of a file before modifying it.
12
+
13
+ Args:
14
+ file_path: Path to file to backup
15
+ backup_dir: Optional custom backup directory (defaults to .instructionkit/backups)
16
+
17
+ Returns:
18
+ Path to created backup file
19
+
20
+ Raises:
21
+ FileNotFoundError: If file doesn't exist
22
+
23
+ Example:
24
+ >>> from pathlib import Path
25
+ >>> backup_path = create_backup(Path(".claude/rules/company.test.md"))
26
+ >>> # Original file preserved at .instructionkit/backups/TIMESTAMP/company.test.md
27
+ """
28
+ if not file_path.exists():
29
+ raise FileNotFoundError(f"Cannot backup non-existent file: {file_path}")
30
+
31
+ # Determine backup directory
32
+ if backup_dir is None:
33
+ # Default: .instructionkit/backups in project root
34
+ from aiconfigkit.utils.project import find_project_root
35
+
36
+ project_root = find_project_root()
37
+ if project_root:
38
+ backup_dir = project_root / ".instructionkit" / "backups"
39
+ else:
40
+ # Fallback to global backups
41
+ backup_dir = Path.home() / ".instructionkit" / "backups"
42
+
43
+ # Create timestamped backup directory
44
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
45
+ timestamped_dir = backup_dir / timestamp
46
+ timestamped_dir.mkdir(parents=True, exist_ok=True)
47
+
48
+ # Preserve relative structure
49
+ # If file is .claude/rules/company.test.md, backup as backups/TIMESTAMP/company.test.md
50
+ backup_path = timestamped_dir / file_path.name
51
+
52
+ # Handle name collision (multiple backups in same second)
53
+ counter = 1
54
+ while backup_path.exists():
55
+ backup_path = timestamped_dir / f"{file_path.stem}_{counter}{file_path.suffix}"
56
+ counter += 1
57
+
58
+ # Copy file to backup
59
+ shutil.copy2(file_path, backup_path)
60
+
61
+ return backup_path
62
+
63
+
64
+ def list_backups(backup_dir: Optional[Path] = None) -> list[tuple[datetime, Path]]:
65
+ """
66
+ List all available backups sorted by date (newest first).
67
+
68
+ Args:
69
+ backup_dir: Optional custom backup directory
70
+
71
+ Returns:
72
+ List of (timestamp, backup_directory) tuples
73
+
74
+ Example:
75
+ >>> backups = list_backups()
76
+ >>> for timestamp, backup_path in backups:
77
+ ... print(f"{timestamp}: {backup_path}")
78
+ """
79
+ if backup_dir is None:
80
+ from aiconfigkit.utils.project import find_project_root
81
+
82
+ project_root = find_project_root()
83
+ if project_root:
84
+ backup_dir = project_root / ".instructionkit" / "backups"
85
+ else:
86
+ backup_dir = Path.home() / ".instructionkit" / "backups"
87
+
88
+ if not backup_dir.exists():
89
+ return []
90
+
91
+ backups: list[tuple[datetime, Path]] = []
92
+ for item in backup_dir.iterdir():
93
+ if item.is_dir():
94
+ try:
95
+ # Parse timestamp from directory name (YYYYMMDD_HHMMSS)
96
+ timestamp = datetime.strptime(item.name, "%Y%m%d_%H%M%S")
97
+ backups.append((timestamp, item))
98
+ except ValueError:
99
+ # Skip directories that don't match timestamp format
100
+ continue
101
+
102
+ # Sort by timestamp, newest first
103
+ backups.sort(reverse=True, key=lambda x: x[0])
104
+ return backups
105
+
106
+
107
+ def cleanup_old_backups(max_age_days: int = 30, backup_dir: Optional[Path] = None) -> int:
108
+ """
109
+ Remove backups older than specified age.
110
+
111
+ Args:
112
+ max_age_days: Maximum age of backups to keep (default: 30 days)
113
+ backup_dir: Optional custom backup directory
114
+
115
+ Returns:
116
+ Number of backup directories removed
117
+
118
+ Example:
119
+ >>> # Remove backups older than 30 days
120
+ >>> removed = cleanup_old_backups(30)
121
+ >>> print(f"Removed {removed} old backup(s)")
122
+ """
123
+ if backup_dir is None:
124
+ from aiconfigkit.utils.project import find_project_root
125
+
126
+ project_root = find_project_root()
127
+ if project_root:
128
+ backup_dir = project_root / ".instructionkit" / "backups"
129
+ else:
130
+ backup_dir = Path.home() / ".instructionkit" / "backups"
131
+
132
+ if not backup_dir.exists():
133
+ return 0
134
+
135
+ cutoff_date = datetime.now().timestamp() - (max_age_days * 24 * 60 * 60)
136
+ removed_count = 0
137
+
138
+ for timestamp, backup_path in list_backups(backup_dir):
139
+ if timestamp.timestamp() < cutoff_date:
140
+ shutil.rmtree(backup_path)
141
+ removed_count += 1
142
+
143
+ return removed_count
144
+
145
+
146
+ def restore_backup(backup_path: Path, target_path: Path) -> None:
147
+ """
148
+ Restore a file from backup.
149
+
150
+ Args:
151
+ backup_path: Path to backup file
152
+ target_path: Where to restore the file
153
+
154
+ Raises:
155
+ FileNotFoundError: If backup doesn't exist
156
+
157
+ Example:
158
+ >>> # Restore from specific backup
159
+ >>> backup = Path(".instructionkit/backups/20251109_143052/company.test.md")
160
+ >>> restore_backup(backup, Path(".claude/rules/company.test.md"))
161
+ """
162
+ if not backup_path.exists():
163
+ raise FileNotFoundError(f"Backup not found: {backup_path}")
164
+
165
+ # Create target directory if needed
166
+ target_path.parent.mkdir(parents=True, exist_ok=True)
167
+
168
+ # Copy backup to target
169
+ shutil.copy2(backup_path, target_path)
@@ -0,0 +1,128 @@
1
+ """Utilities for .env file manipulation."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from dotenv import dotenv_values, set_key
7
+
8
+ from aiconfigkit.core.models import EnvironmentConfig, InstallationScope
9
+ from aiconfigkit.utils.atomic_write import atomic_write
10
+
11
+
12
+ def load_env_config(env_path: Path, scope: InstallationScope) -> EnvironmentConfig:
13
+ """
14
+ Load environment configuration from .env file.
15
+
16
+ Args:
17
+ env_path: Path to .env file
18
+ scope: Installation scope (PROJECT or GLOBAL)
19
+
20
+ Returns:
21
+ EnvironmentConfig object with loaded variables
22
+ """
23
+ variables: dict[str, str] = {}
24
+ if env_path.exists():
25
+ raw_variables = dotenv_values(str(env_path))
26
+ # Filter out None values (empty vars) and ensure all values are strings
27
+ variables = {k: str(v) for k, v in raw_variables.items() if v is not None}
28
+
29
+ return EnvironmentConfig(variables=variables, file_path=str(env_path), scope=scope)
30
+
31
+
32
+ def save_env_config(env_config: EnvironmentConfig) -> None:
33
+ """
34
+ Save environment configuration to .env file using atomic write.
35
+
36
+ Args:
37
+ env_config: EnvironmentConfig to save
38
+ """
39
+ if not env_config.file_path:
40
+ raise ValueError("EnvironmentConfig must have file_path set")
41
+
42
+ env_path = Path(env_config.file_path)
43
+ env_path.parent.mkdir(parents=True, exist_ok=True)
44
+
45
+ # Ensure .gitignore exists for .env file
46
+ ensure_env_gitignored(env_path)
47
+
48
+ # Write .env file atomically
49
+ with atomic_write(env_path, mode="w", encoding="utf-8", create_backup=True) as f:
50
+ f.write("# MCP Server Credentials\n")
51
+ f.write("# Auto-generated by InstructionKit\n")
52
+ f.write("# DO NOT commit this file to version control\n\n")
53
+
54
+ for key, value in sorted(env_config.variables.items()):
55
+ # Escape newlines and quotes in values
56
+ escaped_value = value.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
57
+ f.write(f'{key}="{escaped_value}"\n')
58
+
59
+
60
+ def set_env_variable(env_path: Path, key: str, value: str) -> None:
61
+ """
62
+ Set a single environment variable in .env file.
63
+
64
+ Uses python-dotenv's set_key for safe updating.
65
+
66
+ Args:
67
+ env_path: Path to .env file
68
+ key: Variable name (must be UPPERCASE_WITH_UNDERSCORES)
69
+ value: Variable value
70
+ """
71
+ # Validate key format
72
+ if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
73
+ raise ValueError(f"Invalid environment variable name: {key}. Must match ^[A-Z][A-Z0-9_]*$")
74
+
75
+ env_path.parent.mkdir(parents=True, exist_ok=True)
76
+
77
+ # Ensure .gitignore exists
78
+ ensure_env_gitignored(env_path)
79
+
80
+ # Use python-dotenv's set_key for safe update
81
+ set_key(str(env_path), key, value, quote_mode="always")
82
+
83
+
84
+ def ensure_env_gitignored(env_path: Path) -> None:
85
+ """
86
+ Ensure .env file is in .gitignore.
87
+
88
+ Args:
89
+ env_path: Path to .env file
90
+ """
91
+ gitignore_path = env_path.parent / ".gitignore"
92
+
93
+ # Check if .env is already ignored
94
+ if gitignore_path.exists():
95
+ content = gitignore_path.read_text()
96
+ if ".env" in content:
97
+ return
98
+
99
+ # Add .env to .gitignore
100
+ with open(gitignore_path, "a") as f:
101
+ f.write("\n# MCP Server Credentials (added by InstructionKit)\n")
102
+ f.write(".env\n")
103
+
104
+
105
+ def merge_env_configs(project_config: EnvironmentConfig, global_config: EnvironmentConfig) -> EnvironmentConfig:
106
+ """
107
+ Merge project and global environment configurations.
108
+
109
+ Project variables take precedence over global variables.
110
+
111
+ Args:
112
+ project_config: Project-scoped environment config
113
+ global_config: Global-scoped environment config
114
+
115
+ Returns:
116
+ Merged EnvironmentConfig with project taking precedence
117
+ """
118
+ merged_variables = {}
119
+
120
+ # Start with global variables
121
+ merged_variables.update(global_config.variables)
122
+
123
+ # Override with project variables
124
+ merged_variables.update(project_config.variables)
125
+
126
+ return EnvironmentConfig(
127
+ variables=merged_variables, file_path=project_config.file_path, scope=InstallationScope.PROJECT
128
+ )
@@ -0,0 +1,187 @@
1
+ """Git operations for template repositories."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ try:
7
+ from git import Repo
8
+ from git.exc import GitCommandError, InvalidGitRepositoryError
9
+ except ImportError:
10
+ # GitPython not installed - will be caught by runtime checks
11
+ Repo = None # type: ignore
12
+ GitCommandError = Exception # type: ignore
13
+ InvalidGitRepositoryError = Exception # type: ignore
14
+
15
+
16
+ class TemplateAuthError(Exception):
17
+ """Raised when Git authentication fails for template repository."""
18
+
19
+ pass
20
+
21
+
22
+ class TemplateNetworkError(Exception):
23
+ """Raised when network/repository is unavailable."""
24
+
25
+ pass
26
+
27
+
28
+ class GitPythonNotInstalledError(Exception):
29
+ """Raised when GitPython is not installed."""
30
+
31
+ pass
32
+
33
+
34
+ def _check_gitpython() -> None:
35
+ """Check if GitPython is available."""
36
+ if Repo is None:
37
+ raise GitPythonNotInstalledError("GitPython is required for template sync. Install with: pip install GitPython")
38
+
39
+
40
+ def clone_template_repo(url: str, destination: Path, depth: int = 1) -> "Repo":
41
+ """
42
+ Clone template repository using system Git credentials.
43
+
44
+ Uses Git credential helpers configured on the user's system.
45
+ Supports both HTTPS and SSH URLs.
46
+
47
+ Args:
48
+ url: Git repository URL
49
+ destination: Destination directory for clone
50
+ depth: Clone depth (default 1 for shallow clone)
51
+
52
+ Returns:
53
+ Git Repo object
54
+
55
+ Raises:
56
+ TemplateAuthError: If authentication fails
57
+ TemplateNetworkError: If network/repository unavailable
58
+ GitPythonNotInstalledError: If GitPython not installed
59
+
60
+ Example:
61
+ >>> from pathlib import Path
62
+ >>> repo = clone_template_repo(
63
+ ... "https://github.com/acme/templates",
64
+ ... Path("~/.instructionkit/templates/acme")
65
+ ... )
66
+ """
67
+ _check_gitpython()
68
+
69
+ try:
70
+ repo = Repo.clone_from(
71
+ url=url,
72
+ to_path=str(destination),
73
+ depth=depth,
74
+ env={
75
+ "GIT_TERMINAL_PROMPT": "0", # Fail if credentials needed but not available
76
+ },
77
+ )
78
+ return repo
79
+ except GitCommandError as e:
80
+ error_str = str(e).lower()
81
+
82
+ # Check for authentication failures
83
+ auth_keywords = ["authentication failed", "401", "403", "permission denied", "publickey"]
84
+ if any(keyword in error_str for keyword in auth_keywords):
85
+ raise TemplateAuthError(
86
+ f"Authentication failed for {url}.\n\n"
87
+ f"To configure Git credentials:\n\n"
88
+ f"For HTTPS URLs:\n"
89
+ f" git config --global credential.helper store\n"
90
+ f" # Then perform a git clone manually to cache credentials\n\n"
91
+ f"For SSH URLs:\n"
92
+ f' 1. Generate SSH key: ssh-keygen -t ed25519 -C "your_email@example.com"\n'
93
+ f" 2. Add to GitHub: cat ~/.ssh/id_ed25519.pub\n"
94
+ f" 3. Use SSH URL: git@github.com:org/repo.git\n\n"
95
+ f"Original error: {e}"
96
+ ) from e
97
+
98
+ # Check for repository not found (could be private repo without access)
99
+ if "404" in error_str or "not found" in error_str:
100
+ raise TemplateNetworkError(
101
+ f"Repository not found: {url}\n\n"
102
+ f"This could mean:\n"
103
+ f" - Repository doesn't exist\n"
104
+ f" - Repository is private and you don't have access\n"
105
+ f" - URL is incorrect\n\n"
106
+ f"Original error: {e}"
107
+ ) from e
108
+
109
+ # Other network/Git errors
110
+ raise TemplateNetworkError(f"Failed to clone repository from {url}: {e}") from e
111
+
112
+
113
+ def update_template_repo(repo_path: Path) -> bool:
114
+ """
115
+ Pull latest changes from template repository.
116
+
117
+ Args:
118
+ repo_path: Path to local repository
119
+
120
+ Returns:
121
+ True if updates were pulled, False if already up-to-date
122
+
123
+ Raises:
124
+ TemplateNetworkError: If update fails
125
+ GitPythonNotInstalledError: If GitPython not installed
126
+ InvalidGitRepositoryError: If path is not a Git repository
127
+
128
+ Example:
129
+ >>> from pathlib import Path
130
+ >>> has_updates = update_template_repo(Path("~/.instructionkit/templates/acme"))
131
+ >>> if has_updates:
132
+ ... print("Repository updated!")
133
+ """
134
+ _check_gitpython()
135
+
136
+ try:
137
+ repo = Repo(repo_path)
138
+ except InvalidGitRepositoryError as e:
139
+ raise InvalidGitRepositoryError(f"Not a Git repository: {repo_path}") from e
140
+
141
+ origin = repo.remotes.origin
142
+
143
+ try:
144
+ # Fetch latest changes
145
+ fetch_info = origin.fetch()[0]
146
+
147
+ # Check if remote has changes
148
+ if repo.head.commit == fetch_info.commit:
149
+ return False # Already up-to-date
150
+
151
+ # Pull changes
152
+ origin.pull()
153
+ return True
154
+
155
+ except GitCommandError as e:
156
+ raise TemplateNetworkError(f"Failed to update repository at {repo_path}: {e}") from e
157
+
158
+
159
+ def get_repo_version(repo_path: Path) -> Optional[str]:
160
+ """
161
+ Get current version of repository (latest tag or commit hash).
162
+
163
+ Args:
164
+ repo_path: Path to local repository
165
+
166
+ Returns:
167
+ Version string (tag name or short commit hash)
168
+
169
+ Raises:
170
+ GitPythonNotInstalledError: If GitPython not installed
171
+ InvalidGitRepositoryError: If path is not a Git repository
172
+ """
173
+ _check_gitpython()
174
+
175
+ try:
176
+ repo = Repo(repo_path)
177
+ except InvalidGitRepositoryError as e:
178
+ raise InvalidGitRepositoryError(f"Not a Git repository: {repo_path}") from e
179
+
180
+ # Try to get latest tag
181
+ if repo.tags:
182
+ # Get most recent tag
183
+ latest_tag = sorted(repo.tags, key=lambda t: t.commit.committed_datetime)[-1]
184
+ return str(latest_tag)
185
+
186
+ # Fallback to commit hash
187
+ return repo.head.commit.hexsha[:8]
@@ -0,0 +1,60 @@
1
+ """Logging configuration for InstructionKit."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ def setup_logging(level: str = "INFO", log_file: Optional[Path] = None, format_string: Optional[str] = None) -> None:
10
+ """
11
+ Configure logging for InstructionKit.
12
+
13
+ Args:
14
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
15
+ log_file: Optional file path for logging output
16
+ format_string: Optional custom format string
17
+ """
18
+ if format_string is None:
19
+ format_string = "[%(asctime)s] %(name)s - %(levelname)s - %(message)s"
20
+
21
+ # Convert string level to logging constant
22
+ numeric_level = getattr(logging, level.upper(), logging.INFO)
23
+
24
+ # Create handlers
25
+ handlers: list[logging.Handler] = []
26
+
27
+ # Console handler
28
+ console_handler = logging.StreamHandler(sys.stderr)
29
+ console_handler.setLevel(numeric_level)
30
+ console_handler.setFormatter(logging.Formatter(format_string))
31
+ handlers.append(console_handler)
32
+
33
+ # File handler if specified
34
+ if log_file:
35
+ log_file.parent.mkdir(parents=True, exist_ok=True)
36
+ file_handler = logging.FileHandler(log_file)
37
+ file_handler.setLevel(numeric_level)
38
+ file_handler.setFormatter(logging.Formatter(format_string))
39
+ handlers.append(file_handler)
40
+
41
+ # Configure root logger
42
+ logging.basicConfig(
43
+ level=numeric_level,
44
+ format=format_string,
45
+ handlers=handlers,
46
+ force=True,
47
+ )
48
+
49
+
50
+ def get_logger(name: str) -> logging.Logger:
51
+ """
52
+ Get a logger instance for a module.
53
+
54
+ Args:
55
+ name: Name of the module (usually __name__)
56
+
57
+ Returns:
58
+ Configured logger instance
59
+ """
60
+ return logging.getLogger(name)
@@ -0,0 +1,134 @@
1
+ """Namespace utilities for template repositories."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from urllib.parse import urlparse
7
+
8
+
9
+ def extract_repo_name_from_url(repo_url: str) -> str:
10
+ """
11
+ Extract repository name from Git URL.
12
+
13
+ Handles both HTTPS and SSH URLs.
14
+
15
+ Args:
16
+ repo_url: Git repository URL
17
+
18
+ Returns:
19
+ Repository name (e.g., "coding-standards" from "github.com/org/coding-standards")
20
+
21
+ Examples:
22
+ >>> extract_repo_name_from_url("https://github.com/acme/coding-standards")
23
+ 'coding-standards'
24
+ >>> extract_repo_name_from_url("git@github.com:acme/coding-standards.git")
25
+ 'coding-standards'
26
+ >>> extract_repo_name_from_url("https://github.com/acme/coding-standards.git")
27
+ 'coding-standards'
28
+ """
29
+ # Handle SSH URLs (git@github.com:org/repo.git)
30
+ if repo_url.startswith("git@"):
31
+ # Extract the path after the colon
32
+ _, _, path = repo_url.partition(":")
33
+ repo_name = path.rstrip(".git").split("/")[-1]
34
+ return repo_name
35
+
36
+ # Handle HTTPS URLs
37
+ parsed = urlparse(repo_url)
38
+ path = parsed.path.strip("/")
39
+
40
+ # Remove .git suffix if present
41
+ if path.endswith(".git"):
42
+ path = path[:-4]
43
+
44
+ # Get the last component (repository name)
45
+ repo_name = path.split("/")[-1]
46
+
47
+ return repo_name
48
+
49
+
50
+ def derive_namespace(repo_url: str, override: Optional[str] = None) -> str:
51
+ """
52
+ Derive namespace from repository URL or use override.
53
+
54
+ Always returns a valid namespace. If override provided, validates and returns it.
55
+ Otherwise, derives from repository URL.
56
+
57
+ Args:
58
+ repo_url: Git repository URL
59
+ override: Optional namespace override
60
+
61
+ Returns:
62
+ Valid namespace string
63
+
64
+ Raises:
65
+ ValueError: If override is invalid (empty or contains invalid characters)
66
+
67
+ Examples:
68
+ >>> derive_namespace("https://github.com/acme/coding-standards")
69
+ 'coding-standards'
70
+ >>> derive_namespace("https://github.com/acme/coding-standards", "acme")
71
+ 'acme'
72
+ """
73
+ if override:
74
+ # Validate override namespace
75
+ if not override.strip():
76
+ raise ValueError("Namespace override cannot be empty")
77
+
78
+ # Namespace must be alphanumeric with hyphens only
79
+ if not re.match(r"^[a-zA-Z0-9-]+$", override):
80
+ raise ValueError(f"Invalid namespace '{override}': must contain only alphanumeric characters and hyphens")
81
+
82
+ if len(override) > 50:
83
+ raise ValueError(f"Namespace '{override}' exceeds maximum length of 50 characters")
84
+
85
+ return override
86
+
87
+ # Derive from repository URL
88
+ return extract_repo_name_from_url(repo_url)
89
+
90
+
91
+ def get_install_path(namespace: str, template_name: str, ide_base_path: Path, extension: str) -> Path:
92
+ """
93
+ Construct namespaced installation path using dot notation.
94
+
95
+ Format: {ide_base_path}/{namespace}.{template-name}.{ext}
96
+
97
+ Args:
98
+ namespace: Repository namespace
99
+ template_name: Template identifier
100
+ ide_base_path: Base directory for IDE templates (e.g., .cursor/rules/)
101
+ extension: File extension (e.g., "md", "mdc")
102
+
103
+ Returns:
104
+ Full installation path
105
+
106
+ Examples:
107
+ >>> from pathlib import Path
108
+ >>> path = get_install_path("acme", "test-command", Path(".cursor/rules"), "md")
109
+ >>> str(path)
110
+ '.cursor/rules/acme.test-command.md'
111
+ """
112
+ # Construct namespaced filename: namespace.template-name.ext
113
+ filename = f"{namespace}.{template_name}.{extension}"
114
+ return ide_base_path / filename
115
+
116
+
117
+ def validate_namespace(namespace: str) -> None:
118
+ """
119
+ Validate namespace format.
120
+
121
+ Args:
122
+ namespace: Namespace to validate
123
+
124
+ Raises:
125
+ ValueError: If namespace is invalid
126
+ """
127
+ if not namespace.strip():
128
+ raise ValueError("Namespace cannot be empty")
129
+
130
+ if not re.match(r"^[a-zA-Z0-9-]+$", namespace):
131
+ raise ValueError(f"Invalid namespace '{namespace}': must contain only alphanumeric characters and hyphens")
132
+
133
+ if len(namespace) > 50:
134
+ raise ValueError(f"Namespace '{namespace}' exceeds maximum length of 50 characters")