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.
- aiconfigkit/__init__.py +0 -0
- aiconfigkit/__main__.py +6 -0
- aiconfigkit/ai_tools/__init__.py +0 -0
- aiconfigkit/ai_tools/base.py +236 -0
- aiconfigkit/ai_tools/capability_registry.py +262 -0
- aiconfigkit/ai_tools/claude.py +91 -0
- aiconfigkit/ai_tools/claude_desktop.py +97 -0
- aiconfigkit/ai_tools/cline.py +92 -0
- aiconfigkit/ai_tools/copilot.py +92 -0
- aiconfigkit/ai_tools/cursor.py +109 -0
- aiconfigkit/ai_tools/detector.py +169 -0
- aiconfigkit/ai_tools/kiro.py +85 -0
- aiconfigkit/ai_tools/mcp_syncer.py +291 -0
- aiconfigkit/ai_tools/roo.py +110 -0
- aiconfigkit/ai_tools/translator.py +390 -0
- aiconfigkit/ai_tools/winsurf.py +102 -0
- aiconfigkit/cli/__init__.py +0 -0
- aiconfigkit/cli/delete.py +118 -0
- aiconfigkit/cli/download.py +274 -0
- aiconfigkit/cli/install.py +237 -0
- aiconfigkit/cli/install_new.py +937 -0
- aiconfigkit/cli/list.py +275 -0
- aiconfigkit/cli/main.py +454 -0
- aiconfigkit/cli/mcp_configure.py +232 -0
- aiconfigkit/cli/mcp_install.py +166 -0
- aiconfigkit/cli/mcp_sync.py +165 -0
- aiconfigkit/cli/package.py +383 -0
- aiconfigkit/cli/package_create.py +323 -0
- aiconfigkit/cli/package_install.py +472 -0
- aiconfigkit/cli/template.py +19 -0
- aiconfigkit/cli/template_backup.py +261 -0
- aiconfigkit/cli/template_init.py +499 -0
- aiconfigkit/cli/template_install.py +261 -0
- aiconfigkit/cli/template_list.py +172 -0
- aiconfigkit/cli/template_uninstall.py +146 -0
- aiconfigkit/cli/template_update.py +225 -0
- aiconfigkit/cli/template_validate.py +234 -0
- aiconfigkit/cli/tools.py +47 -0
- aiconfigkit/cli/uninstall.py +125 -0
- aiconfigkit/cli/update.py +309 -0
- aiconfigkit/core/__init__.py +0 -0
- aiconfigkit/core/checksum.py +211 -0
- aiconfigkit/core/component_detector.py +905 -0
- aiconfigkit/core/conflict_resolution.py +329 -0
- aiconfigkit/core/git_operations.py +539 -0
- aiconfigkit/core/mcp/__init__.py +1 -0
- aiconfigkit/core/mcp/credentials.py +279 -0
- aiconfigkit/core/mcp/manager.py +308 -0
- aiconfigkit/core/mcp/set_manager.py +1 -0
- aiconfigkit/core/mcp/validator.py +1 -0
- aiconfigkit/core/models.py +1661 -0
- aiconfigkit/core/package_creator.py +743 -0
- aiconfigkit/core/package_manifest.py +248 -0
- aiconfigkit/core/repository.py +298 -0
- aiconfigkit/core/secret_detector.py +438 -0
- aiconfigkit/core/template_manifest.py +283 -0
- aiconfigkit/core/version.py +201 -0
- aiconfigkit/storage/__init__.py +0 -0
- aiconfigkit/storage/library.py +429 -0
- aiconfigkit/storage/mcp_tracker.py +1 -0
- aiconfigkit/storage/package_tracker.py +234 -0
- aiconfigkit/storage/template_library.py +229 -0
- aiconfigkit/storage/template_tracker.py +296 -0
- aiconfigkit/storage/tracker.py +416 -0
- aiconfigkit/tui/__init__.py +5 -0
- aiconfigkit/tui/installer.py +511 -0
- aiconfigkit/utils/__init__.py +0 -0
- aiconfigkit/utils/atomic_write.py +90 -0
- aiconfigkit/utils/backup.py +169 -0
- aiconfigkit/utils/dotenv.py +128 -0
- aiconfigkit/utils/git_helpers.py +187 -0
- aiconfigkit/utils/logging.py +60 -0
- aiconfigkit/utils/namespace.py +134 -0
- aiconfigkit/utils/paths.py +205 -0
- aiconfigkit/utils/project.py +109 -0
- aiconfigkit/utils/streaming.py +216 -0
- aiconfigkit/utils/ui.py +194 -0
- aiconfigkit/utils/validation.py +187 -0
- devsync-0.5.5.dist-info/LICENSE +21 -0
- devsync-0.5.5.dist-info/METADATA +477 -0
- devsync-0.5.5.dist-info/RECORD +84 -0
- devsync-0.5.5.dist-info/WHEEL +5 -0
- devsync-0.5.5.dist-info/entry_points.txt +2 -0
- 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")
|