skill-seekers 2.7.3__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 (79) hide show
  1. skill_seekers/__init__.py +22 -0
  2. skill_seekers/cli/__init__.py +39 -0
  3. skill_seekers/cli/adaptors/__init__.py +120 -0
  4. skill_seekers/cli/adaptors/base.py +221 -0
  5. skill_seekers/cli/adaptors/claude.py +485 -0
  6. skill_seekers/cli/adaptors/gemini.py +453 -0
  7. skill_seekers/cli/adaptors/markdown.py +269 -0
  8. skill_seekers/cli/adaptors/openai.py +503 -0
  9. skill_seekers/cli/ai_enhancer.py +310 -0
  10. skill_seekers/cli/api_reference_builder.py +373 -0
  11. skill_seekers/cli/architectural_pattern_detector.py +525 -0
  12. skill_seekers/cli/code_analyzer.py +1462 -0
  13. skill_seekers/cli/codebase_scraper.py +1225 -0
  14. skill_seekers/cli/config_command.py +563 -0
  15. skill_seekers/cli/config_enhancer.py +431 -0
  16. skill_seekers/cli/config_extractor.py +871 -0
  17. skill_seekers/cli/config_manager.py +452 -0
  18. skill_seekers/cli/config_validator.py +394 -0
  19. skill_seekers/cli/conflict_detector.py +528 -0
  20. skill_seekers/cli/constants.py +72 -0
  21. skill_seekers/cli/dependency_analyzer.py +757 -0
  22. skill_seekers/cli/doc_scraper.py +2332 -0
  23. skill_seekers/cli/enhance_skill.py +488 -0
  24. skill_seekers/cli/enhance_skill_local.py +1096 -0
  25. skill_seekers/cli/enhance_status.py +194 -0
  26. skill_seekers/cli/estimate_pages.py +433 -0
  27. skill_seekers/cli/generate_router.py +1209 -0
  28. skill_seekers/cli/github_fetcher.py +534 -0
  29. skill_seekers/cli/github_scraper.py +1466 -0
  30. skill_seekers/cli/guide_enhancer.py +723 -0
  31. skill_seekers/cli/how_to_guide_builder.py +1267 -0
  32. skill_seekers/cli/install_agent.py +461 -0
  33. skill_seekers/cli/install_skill.py +178 -0
  34. skill_seekers/cli/language_detector.py +614 -0
  35. skill_seekers/cli/llms_txt_detector.py +60 -0
  36. skill_seekers/cli/llms_txt_downloader.py +104 -0
  37. skill_seekers/cli/llms_txt_parser.py +150 -0
  38. skill_seekers/cli/main.py +558 -0
  39. skill_seekers/cli/markdown_cleaner.py +132 -0
  40. skill_seekers/cli/merge_sources.py +806 -0
  41. skill_seekers/cli/package_multi.py +77 -0
  42. skill_seekers/cli/package_skill.py +241 -0
  43. skill_seekers/cli/pattern_recognizer.py +1825 -0
  44. skill_seekers/cli/pdf_extractor_poc.py +1166 -0
  45. skill_seekers/cli/pdf_scraper.py +617 -0
  46. skill_seekers/cli/quality_checker.py +519 -0
  47. skill_seekers/cli/rate_limit_handler.py +438 -0
  48. skill_seekers/cli/resume_command.py +160 -0
  49. skill_seekers/cli/run_tests.py +230 -0
  50. skill_seekers/cli/setup_wizard.py +93 -0
  51. skill_seekers/cli/split_config.py +390 -0
  52. skill_seekers/cli/swift_patterns.py +560 -0
  53. skill_seekers/cli/test_example_extractor.py +1081 -0
  54. skill_seekers/cli/test_unified_simple.py +179 -0
  55. skill_seekers/cli/unified_codebase_analyzer.py +572 -0
  56. skill_seekers/cli/unified_scraper.py +932 -0
  57. skill_seekers/cli/unified_skill_builder.py +1605 -0
  58. skill_seekers/cli/upload_skill.py +162 -0
  59. skill_seekers/cli/utils.py +432 -0
  60. skill_seekers/mcp/__init__.py +33 -0
  61. skill_seekers/mcp/agent_detector.py +316 -0
  62. skill_seekers/mcp/git_repo.py +273 -0
  63. skill_seekers/mcp/server.py +231 -0
  64. skill_seekers/mcp/server_fastmcp.py +1249 -0
  65. skill_seekers/mcp/server_legacy.py +2302 -0
  66. skill_seekers/mcp/source_manager.py +285 -0
  67. skill_seekers/mcp/tools/__init__.py +115 -0
  68. skill_seekers/mcp/tools/config_tools.py +251 -0
  69. skill_seekers/mcp/tools/packaging_tools.py +826 -0
  70. skill_seekers/mcp/tools/scraping_tools.py +842 -0
  71. skill_seekers/mcp/tools/source_tools.py +828 -0
  72. skill_seekers/mcp/tools/splitting_tools.py +212 -0
  73. skill_seekers/py.typed +0 -0
  74. skill_seekers-2.7.3.dist-info/METADATA +2027 -0
  75. skill_seekers-2.7.3.dist-info/RECORD +79 -0
  76. skill_seekers-2.7.3.dist-info/WHEEL +5 -0
  77. skill_seekers-2.7.3.dist-info/entry_points.txt +19 -0
  78. skill_seekers-2.7.3.dist-info/licenses/LICENSE +21 -0
  79. skill_seekers-2.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,316 @@
1
+ """
2
+ AI Coding Agent Detection and Configuration Module
3
+
4
+ This module provides functionality to detect installed AI coding agents
5
+ and generate appropriate MCP server configurations for each agent.
6
+
7
+ Supported agents:
8
+ - Claude Code (stdio)
9
+ - Cursor (HTTP)
10
+ - Windsurf (HTTP)
11
+ - VS Code + Cline extension (stdio)
12
+ - IntelliJ IDEA (HTTP)
13
+ """
14
+
15
+ import json
16
+ import platform
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ class AgentDetector:
22
+ """Detects installed AI coding agents and generates their MCP configurations."""
23
+
24
+ # Agent configuration templates
25
+ AGENT_CONFIG = {
26
+ "claude-code": {
27
+ "name": "Claude Code",
28
+ "transport": "stdio",
29
+ "config_paths": {
30
+ "Linux": "~/.claude.json",
31
+ "Darwin": "~/.claude.json",
32
+ "Windows": "~/.claude.json",
33
+ },
34
+ },
35
+ "cursor": {
36
+ "name": "Cursor",
37
+ "transport": "http",
38
+ "config_paths": {
39
+ "Linux": "~/.cursor/mcp_settings.json",
40
+ "Darwin": "~/Library/Application Support/Cursor/mcp_settings.json",
41
+ "Windows": "~\\AppData\\Roaming\\Cursor\\mcp_settings.json",
42
+ },
43
+ },
44
+ "windsurf": {
45
+ "name": "Windsurf",
46
+ "transport": "http",
47
+ "config_paths": {
48
+ "Linux": "~/.windsurf/mcp_config.json",
49
+ "Darwin": "~/Library/Application Support/Windsurf/mcp_config.json",
50
+ "Windows": "~\\AppData\\Roaming\\Windsurf\\mcp_config.json",
51
+ },
52
+ },
53
+ "vscode-cline": {
54
+ "name": "VS Code + Cline",
55
+ "transport": "stdio",
56
+ "config_paths": {
57
+ "Linux": "~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
58
+ "Darwin": "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
59
+ "Windows": "~\\AppData\\Roaming\\Code\\User\\globalStorage\\saoudrizwan.claude-dev\\settings\\cline_mcp_settings.json",
60
+ },
61
+ },
62
+ "intellij": {
63
+ "name": "IntelliJ IDEA",
64
+ "transport": "http",
65
+ "config_paths": {
66
+ "Linux": "~/.config/JetBrains/IntelliJIdea2024.3/mcp.xml",
67
+ "Darwin": "~/Library/Application Support/JetBrains/IntelliJIdea2024.3/mcp.xml",
68
+ "Windows": "~\\AppData\\Roaming\\JetBrains\\IntelliJIdea2024.3\\mcp.xml",
69
+ },
70
+ },
71
+ }
72
+
73
+ def __init__(self):
74
+ """Initialize the agent detector."""
75
+ self.system = platform.system()
76
+
77
+ def detect_agents(self) -> list[dict[str, str]]:
78
+ """
79
+ Detect installed AI coding agents on the system.
80
+
81
+ Returns:
82
+ List of detected agents with their config paths.
83
+ Each dict contains: {'agent': str, 'name': str, 'config_path': str, 'transport': str}
84
+ """
85
+ detected = []
86
+
87
+ for agent_id, config in self.AGENT_CONFIG.items():
88
+ config_path = self._get_config_path(agent_id)
89
+ if config_path:
90
+ detected.append(
91
+ {
92
+ "agent": agent_id,
93
+ "name": config["name"],
94
+ "config_path": config_path,
95
+ "transport": config["transport"],
96
+ }
97
+ )
98
+
99
+ return detected
100
+
101
+ def _get_config_path(self, agent_id: str) -> str | None:
102
+ """
103
+ Get the configuration path for a specific agent.
104
+
105
+ Args:
106
+ agent_id: Agent identifier (e.g., 'claude-code', 'cursor')
107
+
108
+ Returns:
109
+ Expanded config path if the parent directory exists, None otherwise
110
+ """
111
+ if agent_id not in self.AGENT_CONFIG:
112
+ return None
113
+
114
+ config_paths = self.AGENT_CONFIG[agent_id]["config_paths"]
115
+ if self.system not in config_paths:
116
+ return None
117
+
118
+ path = Path(config_paths[self.system]).expanduser()
119
+
120
+ # Check if parent directory exists (agent is likely installed)
121
+ parent = path.parent
122
+ if parent.exists():
123
+ return str(path)
124
+
125
+ return None
126
+
127
+ def get_transport_type(self, agent_id: str) -> str | None:
128
+ """
129
+ Get the transport type for a specific agent.
130
+
131
+ Args:
132
+ agent_id: Agent identifier
133
+
134
+ Returns:
135
+ 'stdio' or 'http', or None if agent not found
136
+ """
137
+ if agent_id not in self.AGENT_CONFIG:
138
+ return None
139
+ return self.AGENT_CONFIG[agent_id]["transport"]
140
+
141
+ def generate_config(
142
+ self, agent_id: str, server_command: str, http_port: int | None = 3000
143
+ ) -> str | None:
144
+ """
145
+ Generate MCP configuration for a specific agent.
146
+
147
+ Args:
148
+ agent_id: Agent identifier
149
+ server_command: Command to start the MCP server (e.g., 'skill-seekers mcp')
150
+ http_port: Port for HTTP transport (default: 3000)
151
+
152
+ Returns:
153
+ Configuration string (JSON or XML) or None if agent not found
154
+ """
155
+ if agent_id not in self.AGENT_CONFIG:
156
+ return None
157
+
158
+ transport = self.AGENT_CONFIG[agent_id]["transport"]
159
+
160
+ if agent_id == "intellij":
161
+ return self._generate_intellij_config(server_command, http_port)
162
+ elif transport == "stdio":
163
+ return self._generate_stdio_config(server_command)
164
+ else: # http
165
+ return self._generate_http_config(http_port)
166
+
167
+ def _generate_stdio_config(self, server_command: str) -> str:
168
+ """
169
+ Generate stdio-based MCP configuration (JSON format).
170
+
171
+ Args:
172
+ server_command: Command to start the MCP server
173
+
174
+ Returns:
175
+ JSON configuration string
176
+ """
177
+ # Split command into program and args
178
+ parts = server_command.split()
179
+ command = parts[0] if parts else "skill-seekers"
180
+ args = parts[1:] if len(parts) > 1 else ["mcp"]
181
+
182
+ config = {"mcpServers": {"skill-seeker": {"command": command, "args": args}}}
183
+
184
+ return json.dumps(config, indent=2)
185
+
186
+ def _generate_http_config(self, http_port: int) -> str:
187
+ """
188
+ Generate HTTP-based MCP configuration (JSON format).
189
+
190
+ Args:
191
+ http_port: Port number for HTTP server
192
+
193
+ Returns:
194
+ JSON configuration string
195
+ """
196
+ config = {"mcpServers": {"skill-seeker": {"url": f"http://localhost:{http_port}"}}}
197
+
198
+ return json.dumps(config, indent=2)
199
+
200
+ def _generate_intellij_config(self, _server_command: str, http_port: int) -> str:
201
+ """
202
+ Generate IntelliJ IDEA MCP configuration (XML format).
203
+
204
+ Args:
205
+ server_command: Command to start the MCP server
206
+ http_port: Port number for HTTP server
207
+
208
+ Returns:
209
+ XML configuration string
210
+ """
211
+ xml = f"""<?xml version="1.0" encoding="UTF-8"?>
212
+ <application>
213
+ <component name="MCPSettings">
214
+ <servers>
215
+ <server>
216
+ <name>skill-seeker</name>
217
+ <url>http://localhost:{http_port}</url>
218
+ <enabled>true</enabled>
219
+ </server>
220
+ </servers>
221
+ </component>
222
+ </application>"""
223
+ return xml
224
+
225
+ def get_all_config_paths(self) -> dict[str, str]:
226
+ """
227
+ Get all possible configuration paths for the current system.
228
+
229
+ Returns:
230
+ Dict mapping agent_id to config_path
231
+ """
232
+ paths = {}
233
+ for agent_id in self.AGENT_CONFIG:
234
+ path = self._get_config_path(agent_id)
235
+ if path:
236
+ paths[agent_id] = path
237
+ return paths
238
+
239
+ def is_agent_installed(self, agent_id: str) -> bool:
240
+ """
241
+ Check if a specific agent is installed.
242
+
243
+ Args:
244
+ agent_id: Agent identifier
245
+
246
+ Returns:
247
+ True if agent appears to be installed, False otherwise
248
+ """
249
+ return self._get_config_path(agent_id) is not None
250
+
251
+ def get_agent_info(self, agent_id: str) -> dict[str, Any] | None:
252
+ """
253
+ Get detailed information about a specific agent.
254
+
255
+ Args:
256
+ agent_id: Agent identifier
257
+
258
+ Returns:
259
+ Dict with agent details or None if not found
260
+ """
261
+ if agent_id not in self.AGENT_CONFIG:
262
+ return None
263
+
264
+ config = self.AGENT_CONFIG[agent_id]
265
+ config_path = self._get_config_path(agent_id)
266
+
267
+ return {
268
+ "agent": agent_id,
269
+ "name": config["name"],
270
+ "transport": config["transport"],
271
+ "config_path": config_path,
272
+ "installed": config_path is not None,
273
+ }
274
+
275
+
276
+ def detect_agents() -> list[dict[str, str]]:
277
+ """
278
+ Convenience function to detect installed agents.
279
+
280
+ Returns:
281
+ List of detected agents
282
+ """
283
+ detector = AgentDetector()
284
+ return detector.detect_agents()
285
+
286
+
287
+ def generate_config(
288
+ agent_name: str, server_command: str = "skill-seekers mcp", http_port: int = 3000
289
+ ) -> str | None:
290
+ """
291
+ Convenience function to generate config for a specific agent.
292
+
293
+ Args:
294
+ agent_name: Agent identifier
295
+ server_command: Command to start the MCP server
296
+ http_port: Port for HTTP transport
297
+
298
+ Returns:
299
+ Configuration string or None
300
+ """
301
+ detector = AgentDetector()
302
+ return detector.generate_config(agent_name, server_command, http_port)
303
+
304
+
305
+ def get_transport_type(agent_name: str) -> str | None:
306
+ """
307
+ Convenience function to get transport type for an agent.
308
+
309
+ Args:
310
+ agent_name: Agent identifier
311
+
312
+ Returns:
313
+ 'stdio' or 'http', or None
314
+ """
315
+ detector = AgentDetector()
316
+ return detector.get_transport_type(agent_name)
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git Config Repository Manager
4
+ Handles git clone/pull operations for custom config sources
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ from pathlib import Path
11
+ from urllib.parse import urlparse
12
+
13
+ import git
14
+ from git.exc import GitCommandError, InvalidGitRepositoryError
15
+
16
+
17
+ class GitConfigRepo:
18
+ """Manages git operations for config repositories."""
19
+
20
+ def __init__(self, cache_dir: str | None = None):
21
+ """
22
+ Initialize git repository manager.
23
+
24
+ Args:
25
+ cache_dir: Base cache directory. Defaults to $SKILL_SEEKERS_CACHE_DIR
26
+ or ~/.skill-seekers/cache/
27
+ """
28
+ if cache_dir:
29
+ self.cache_dir = Path(cache_dir)
30
+ else:
31
+ # Use environment variable or default
32
+ env_cache = os.environ.get("SKILL_SEEKERS_CACHE_DIR")
33
+ if env_cache:
34
+ self.cache_dir = Path(env_cache).expanduser()
35
+ else:
36
+ self.cache_dir = Path.home() / ".skill-seekers" / "cache"
37
+
38
+ # Ensure cache directory exists
39
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ def clone_or_pull(
42
+ self,
43
+ source_name: str,
44
+ git_url: str,
45
+ branch: str = "main",
46
+ token: str | None = None,
47
+ force_refresh: bool = False,
48
+ ) -> Path:
49
+ """
50
+ Clone repository if not cached, else pull latest changes.
51
+
52
+ Args:
53
+ source_name: Source identifier (used for cache path)
54
+ git_url: Git repository URL
55
+ branch: Branch to clone/pull (default: main)
56
+ token: Optional authentication token
57
+ force_refresh: If True, delete cache and re-clone
58
+
59
+ Returns:
60
+ Path to cloned repository
61
+
62
+ Raises:
63
+ GitCommandError: If clone/pull fails
64
+ ValueError: If git_url is invalid
65
+ """
66
+ # Validate URL
67
+ if not self.validate_git_url(git_url):
68
+ raise ValueError(f"Invalid git URL: {git_url}")
69
+
70
+ # Determine cache path
71
+ repo_path = self.cache_dir / source_name
72
+
73
+ # Force refresh: delete existing cache
74
+ if force_refresh and repo_path.exists():
75
+ shutil.rmtree(repo_path)
76
+
77
+ # Inject token if provided
78
+ clone_url = git_url
79
+ if token:
80
+ clone_url = self.inject_token(git_url, token)
81
+
82
+ try:
83
+ if repo_path.exists() and (repo_path / ".git").exists():
84
+ # Repository exists - pull latest
85
+ try:
86
+ repo = git.Repo(repo_path)
87
+ origin = repo.remotes.origin
88
+
89
+ # Update remote URL if token provided
90
+ if token:
91
+ origin.set_url(clone_url)
92
+
93
+ # Pull latest changes
94
+ origin.pull(branch)
95
+ return repo_path
96
+ except (InvalidGitRepositoryError, GitCommandError):
97
+ # Corrupted repo - delete and re-clone
98
+ shutil.rmtree(repo_path)
99
+ raise # Re-raise to trigger clone below
100
+
101
+ # Repository doesn't exist - clone
102
+ git.Repo.clone_from(
103
+ clone_url,
104
+ repo_path,
105
+ branch=branch,
106
+ depth=1, # Shallow clone
107
+ single_branch=True, # Only clone one branch
108
+ )
109
+ return repo_path
110
+
111
+ except GitCommandError as e:
112
+ error_msg = str(e)
113
+
114
+ # Provide helpful error messages
115
+ if "authentication failed" in error_msg.lower() or "403" in error_msg:
116
+ raise GitCommandError(
117
+ f"Authentication failed for {git_url}. Check your token or permissions.", 128
118
+ ) from e
119
+ elif "not found" in error_msg.lower() or "404" in error_msg:
120
+ raise GitCommandError(
121
+ f"Repository not found: {git_url}. Verify the URL is correct and you have access.",
122
+ 128,
123
+ ) from e
124
+ else:
125
+ raise GitCommandError(f"Failed to clone repository: {error_msg}", 128) from e
126
+
127
+ def find_configs(self, repo_path: Path) -> list[Path]:
128
+ """
129
+ Find all config files (*.json) in repository.
130
+
131
+ Args:
132
+ repo_path: Path to cloned repo
133
+
134
+ Returns:
135
+ List of paths to *.json files (sorted by name)
136
+ """
137
+ if not repo_path.exists():
138
+ return []
139
+
140
+ # Find all .json files, excluding .git directory
141
+ configs = []
142
+ for json_file in repo_path.rglob("*.json"):
143
+ # Skip files in .git directory
144
+ if ".git" in json_file.parts:
145
+ continue
146
+ configs.append(json_file)
147
+
148
+ # Sort by filename
149
+ return sorted(configs, key=lambda p: p.name)
150
+
151
+ def get_config(self, repo_path: Path, config_name: str) -> dict:
152
+ """
153
+ Load specific config by name from repository.
154
+
155
+ Args:
156
+ repo_path: Path to cloned repo
157
+ config_name: Config name (without .json extension)
158
+
159
+ Returns:
160
+ Config dictionary
161
+
162
+ Raises:
163
+ FileNotFoundError: If config not found
164
+ ValueError: If config is invalid JSON
165
+ """
166
+ # Ensure .json extension
167
+ if not config_name.endswith(".json"):
168
+ config_name = f"{config_name}.json"
169
+
170
+ # Search for config file
171
+ all_configs = self.find_configs(repo_path)
172
+
173
+ # Try exact filename match first
174
+ for config_path in all_configs:
175
+ if config_path.name == config_name:
176
+ return self._load_config_file(config_path)
177
+
178
+ # Try case-insensitive match
179
+ config_name_lower = config_name.lower()
180
+ for config_path in all_configs:
181
+ if config_path.name.lower() == config_name_lower:
182
+ return self._load_config_file(config_path)
183
+
184
+ # Config not found - provide helpful error
185
+ available = [p.stem for p in all_configs] # Just filenames without .json
186
+ raise FileNotFoundError(
187
+ f"Config '{config_name}' not found in repository. "
188
+ f"Available configs: {', '.join(available) if available else 'none'}"
189
+ )
190
+
191
+ def _load_config_file(self, config_path: Path) -> dict:
192
+ """
193
+ Load and validate config JSON file.
194
+
195
+ Args:
196
+ config_path: Path to config file
197
+
198
+ Returns:
199
+ Config dictionary
200
+
201
+ Raises:
202
+ ValueError: If JSON is invalid
203
+ """
204
+ try:
205
+ with open(config_path, encoding="utf-8") as f:
206
+ return json.load(f)
207
+ except json.JSONDecodeError as e:
208
+ raise ValueError(f"Invalid JSON in config file {config_path.name}: {e}") from e
209
+
210
+ @staticmethod
211
+ def inject_token(git_url: str, token: str) -> str:
212
+ """
213
+ Inject authentication token into git URL.
214
+
215
+ Converts SSH URLs to HTTPS and adds token for authentication.
216
+
217
+ Args:
218
+ git_url: Original git URL
219
+ token: Authentication token
220
+
221
+ Returns:
222
+ URL with token injected
223
+
224
+ Examples:
225
+ https://github.com/org/repo.git → https://TOKEN@github.com/org/repo.git
226
+ git@github.com:org/repo.git → https://TOKEN@github.com/org/repo.git
227
+ """
228
+ # Convert SSH to HTTPS
229
+ if git_url.startswith("git@"):
230
+ # git@github.com:org/repo.git → github.com/org/repo.git
231
+ parts = git_url.replace("git@", "").replace(":", "/", 1)
232
+ git_url = f"https://{parts}"
233
+
234
+ # Parse URL
235
+ parsed = urlparse(git_url)
236
+
237
+ # Inject token
238
+ if parsed.hostname:
239
+ # https://github.com/org/repo.git → https://TOKEN@github.com/org/repo.git
240
+ netloc = f"{token}@{parsed.hostname}"
241
+ if parsed.port:
242
+ netloc = f"{netloc}:{parsed.port}"
243
+
244
+ return f"{parsed.scheme}://{netloc}{parsed.path}"
245
+
246
+ return git_url
247
+
248
+ @staticmethod
249
+ def validate_git_url(git_url: str) -> bool:
250
+ """
251
+ Validate git URL format.
252
+
253
+ Args:
254
+ git_url: Git repository URL
255
+
256
+ Returns:
257
+ True if valid, False otherwise
258
+ """
259
+ if not git_url:
260
+ return False
261
+
262
+ # Accept HTTPS URLs
263
+ if git_url.startswith("https://") or git_url.startswith("http://"):
264
+ parsed = urlparse(git_url)
265
+ return bool(parsed.hostname and parsed.path)
266
+
267
+ # Accept SSH URLs
268
+ if git_url.startswith("git@"):
269
+ # git@github.com:org/repo.git
270
+ return ":" in git_url and len(git_url.split(":")) == 2
271
+
272
+ # Accept file:// URLs (for local testing)
273
+ return bool(git_url.startswith("file://"))