devsync 0.12.0__tar.gz → 0.13.0__tar.gz

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 (98) hide show
  1. {devsync-0.12.0 → devsync-0.13.0}/PKG-INFO +2 -1
  2. {devsync-0.12.0 → devsync-0.13.0}/README.md +1 -0
  3. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/install_v2.py +96 -10
  4. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/main.py +9 -0
  5. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/component_detector.py +29 -0
  6. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/extractor.py +2 -1
  7. devsync-0.13.0/devsync/core/pip_utils.py +314 -0
  8. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/practice.py +9 -0
  9. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/prompts.py +5 -1
  10. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/PKG-INFO +2 -1
  11. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/SOURCES.txt +1 -0
  12. {devsync-0.12.0 → devsync-0.13.0}/pyproject.toml +1 -1
  13. {devsync-0.12.0 → devsync-0.13.0}/LICENSE +0 -0
  14. {devsync-0.12.0 → devsync-0.13.0}/devsync/__init__.py +0 -0
  15. {devsync-0.12.0 → devsync-0.13.0}/devsync/__main__.py +0 -0
  16. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/__init__.py +0 -0
  17. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/aider.py +0 -0
  18. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/amazonq.py +0 -0
  19. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/amp.py +0 -0
  20. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/anteroom.py +0 -0
  21. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/antigravity.py +0 -0
  22. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/augment.py +0 -0
  23. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/base.py +0 -0
  24. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/capability_registry.py +0 -0
  25. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/claude.py +0 -0
  26. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/claude_desktop.py +0 -0
  27. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/cline.py +0 -0
  28. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/codex.py +0 -0
  29. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/continuedev.py +0 -0
  30. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/copilot.py +0 -0
  31. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/cursor.py +0 -0
  32. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/detector.py +0 -0
  33. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/gemini.py +0 -0
  34. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/jetbrains.py +0 -0
  35. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/junie.py +0 -0
  36. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/kiro.py +0 -0
  37. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/mcp_syncer.py +0 -0
  38. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/opencode.py +0 -0
  39. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/openhands.py +0 -0
  40. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/roo.py +0 -0
  41. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/tabnine.py +0 -0
  42. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/trae.py +0 -0
  43. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/translator.py +0 -0
  44. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/winsurf.py +0 -0
  45. {devsync-0.12.0 → devsync-0.13.0}/devsync/ai_tools/zed.py +0 -0
  46. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/__init__.py +0 -0
  47. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/extract.py +0 -0
  48. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/list_v2.py +0 -0
  49. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/setup.py +0 -0
  50. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/tools.py +0 -0
  51. {devsync-0.12.0 → devsync-0.13.0}/devsync/cli/uninstall.py +0 -0
  52. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/__init__.py +0 -0
  53. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/adapter.py +0 -0
  54. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/checksum.py +0 -0
  55. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/conflict_resolution.py +0 -0
  56. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/git_operations.py +0 -0
  57. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/__init__.py +0 -0
  58. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/credentials.py +0 -0
  59. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/manager.py +0 -0
  60. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/set_manager.py +0 -0
  61. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp/validator.py +0 -0
  62. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/mcp_credential_prompter.py +0 -0
  63. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/models.py +0 -0
  64. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_creator.py +0 -0
  65. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_manifest.py +0 -0
  66. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/package_manifest_v2.py +0 -0
  67. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/repository.py +0 -0
  68. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/secret_detector.py +0 -0
  69. {devsync-0.12.0 → devsync-0.13.0}/devsync/core/version.py +0 -0
  70. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/__init__.py +0 -0
  71. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/anthropic.py +0 -0
  72. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/config.py +0 -0
  73. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/openai_provider.py +0 -0
  74. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/openrouter.py +0 -0
  75. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/provider.py +0 -0
  76. {devsync-0.12.0 → devsync-0.13.0}/devsync/llm/response_models.py +0 -0
  77. {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/__init__.py +0 -0
  78. {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/mcp_tracker.py +0 -0
  79. {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/package_tracker.py +0 -0
  80. {devsync-0.12.0 → devsync-0.13.0}/devsync/storage/tracker.py +0 -0
  81. {devsync-0.12.0 → devsync-0.13.0}/devsync/tui/__init__.py +0 -0
  82. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/__init__.py +0 -0
  83. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/atomic_write.py +0 -0
  84. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/backup.py +0 -0
  85. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/dotenv.py +0 -0
  86. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/git_helpers.py +0 -0
  87. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/logging.py +0 -0
  88. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/namespace.py +0 -0
  89. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/paths.py +0 -0
  90. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/project.py +0 -0
  91. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/streaming.py +0 -0
  92. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/ui.py +0 -0
  93. {devsync-0.12.0 → devsync-0.13.0}/devsync/utils/validation.py +0 -0
  94. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/dependency_links.txt +0 -0
  95. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/entry_points.txt +0 -0
  96. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/requires.txt +0 -0
  97. {devsync-0.12.0 → devsync-0.13.0}/devsync.egg-info/top_level.txt +0 -0
  98. {devsync-0.12.0 → devsync-0.13.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devsync
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Distribute and sync dev tool configurations across teams
5
5
  Author-email: Troy Larson <troy@calvinware.com>
6
6
  License: MIT License
@@ -86,6 +86,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
86
86
  - **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
87
87
  - **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
88
88
  - **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
89
+ - **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
89
90
  - **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
90
91
  - **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
91
92
  - **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
@@ -47,6 +47,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
47
47
  - **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
48
48
  - **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
49
49
  - **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
50
+ - **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
50
51
  - **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
51
52
  - **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
52
53
  - **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
@@ -1,9 +1,14 @@
1
1
  """V2 install command — AI-powered package installation."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  import shutil
4
6
  import tempfile
5
7
  from pathlib import Path
6
- from typing import Optional
8
+ from typing import TYPE_CHECKING, Optional
9
+
10
+ if TYPE_CHECKING:
11
+ from devsync.core.practice import MCPDeclaration
7
12
 
8
13
  from rich.console import Console
9
14
  from rich.prompt import Confirm
@@ -26,6 +31,7 @@ def install_v2_command(
26
31
  no_ai: bool = False,
27
32
  conflict: str = "prompt",
28
33
  project_dir: Optional[str] = None,
34
+ skip_pip: bool = False,
29
35
  ) -> int:
30
36
  """Install a package into the current project.
31
37
 
@@ -37,6 +43,7 @@ def install_v2_command(
37
43
  no_ai: Disable AI-powered adaptation.
38
44
  conflict: Conflict strategy ('prompt', 'skip', 'overwrite', 'rename').
39
45
  project_dir: Target project directory. Defaults to cwd.
46
+ skip_pip: Skip pip package installations for MCP servers.
40
47
 
41
48
  Returns:
42
49
  Exit code (0 = success).
@@ -72,8 +79,8 @@ def install_v2_command(
72
79
  console.print(f" Tools: {', '.join(target_tools)}")
73
80
 
74
81
  if manifest.is_v2 and manifest.has_practices and not no_ai:
75
- return _install_v2_ai(manifest, project_root, target_tools)
76
- return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict)
82
+ return _install_v2_ai(manifest, project_root, target_tools, skip_pip=skip_pip)
83
+ return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict, skip_pip=skip_pip)
77
84
  finally:
78
85
  if cloned_tmp and cloned_tmp.exists():
79
86
  shutil.rmtree(cloned_tmp, ignore_errors=True)
@@ -125,6 +132,7 @@ def _install_v2_ai(
125
132
  manifest: PackageManifestV2,
126
133
  project_root: Path,
127
134
  target_tools: list[str],
135
+ skip_pip: bool = False,
128
136
  ) -> int:
129
137
  """Install using AI-powered adaptation."""
130
138
  config = load_config()
@@ -142,7 +150,7 @@ def _install_v2_ai(
142
150
  _execute_plan(plan, project_root, target_tools)
143
151
 
144
152
  if manifest.mcp_servers:
145
- _install_mcp_servers(manifest, project_root)
153
+ _install_mcp_servers(manifest, project_root, skip_pip=skip_pip)
146
154
 
147
155
  console.print(f"\n[green]Installed {manifest.name} successfully.[/green]")
148
156
  return 0
@@ -154,6 +162,7 @@ def _install_v2_fallback(
154
162
  project_root: Path,
155
163
  target_tools: list[str],
156
164
  conflict: str,
165
+ skip_pip: bool = False,
157
166
  ) -> int:
158
167
  """Install using file-copy mode (v1 compat or --no-ai)."""
159
168
  installed_count = 0
@@ -205,7 +214,7 @@ def _install_v2_fallback(
205
214
  console.print(f" Installed: {ref.name} → {dest.relative_to(project_root)}")
206
215
 
207
216
  if manifest.mcp_servers:
208
- _install_mcp_servers(manifest, project_root)
217
+ _install_mcp_servers(manifest, project_root, skip_pip=skip_pip)
209
218
 
210
219
  console.print(f"\n[green]Installed {installed_count} instructions.[/green]")
211
220
  return 0
@@ -258,17 +267,94 @@ def _get_tool_instruction_path(tool_name: str, project_root: Path, instruction_n
258
267
  return project_root / dir_name / f"{instruction_name}{ext}"
259
268
 
260
269
 
261
- def _install_mcp_servers(manifest: PackageManifestV2, project_root: Path) -> None:
262
- """Install MCP server configurations with credential prompting."""
263
- servers_with_creds = [s for s in manifest.mcp_servers if s.credentials]
270
+ def _install_mcp_servers(
271
+ manifest: PackageManifestV2,
272
+ project_root: Path,
273
+ skip_pip: bool = False,
274
+ ) -> None:
275
+ """Install MCP server configurations with pip dependencies and credential prompting."""
276
+ failed_pip_servers = _install_pip_dependencies(manifest.mcp_servers, skip_pip=skip_pip)
277
+
278
+ # Skip credential prompting for servers whose pip deps failed
279
+ eligible_servers = [s for s in manifest.mcp_servers if s.name not in failed_pip_servers]
280
+ servers_with_creds = [s for s in eligible_servers if s.credentials]
264
281
  if servers_with_creds:
265
282
  env_path = project_root / ".devsync" / ".env"
266
283
  credentials = prompt_mcp_credentials(servers_with_creds, env_path=env_path)
267
284
 
268
- for server in manifest.mcp_servers:
285
+ for server in eligible_servers:
269
286
  server_creds = credentials.get(server.name, {})
270
287
  build_mcp_config(server, server_creds)
271
288
  console.print(f" MCP: {server.name} configured")
272
289
  else:
273
- for server in manifest.mcp_servers:
290
+ for server in eligible_servers:
274
291
  console.print(f" MCP: {server.name} (no credentials needed)")
292
+
293
+
294
+ def _install_pip_dependencies(
295
+ mcp_servers: list[MCPDeclaration],
296
+ skip_pip: bool = False,
297
+ ) -> set[str]:
298
+ """Install pip package dependencies for MCP servers.
299
+
300
+ Args:
301
+ mcp_servers: List of MCPDeclaration objects.
302
+ skip_pip: If True, skip all pip installations.
303
+
304
+ Returns:
305
+ Set of server names whose pip dependency installation failed or was declined.
306
+ """
307
+ from devsync.core.pip_utils import (
308
+ get_installed_version,
309
+ install_pip_package,
310
+ installed_version_satisfies,
311
+ validate_pip_spec,
312
+ )
313
+
314
+ failed_servers: set[str] = set()
315
+ servers_with_pip = [s for s in mcp_servers if s.pip_package]
316
+ if not servers_with_pip:
317
+ return failed_servers
318
+
319
+ console.print("\n[bold]MCP Server Dependencies[/bold]")
320
+
321
+ if skip_pip:
322
+ console.print(" [yellow]Skipping pip installations (--skip-pip)[/yellow]")
323
+ for server in servers_with_pip:
324
+ console.print(f" [dim]{server.name}: {server.pip_package} (skipped)[/dim]")
325
+ return failed_servers
326
+
327
+ for server in servers_with_pip:
328
+ spec = server.pip_package
329
+ assert spec is not None # guarded by servers_with_pip filter
330
+
331
+ if not validate_pip_spec(spec):
332
+ console.print(f" [red]Invalid package spec for {server.name}: {spec}[/red]")
333
+ failed_servers.add(server.name)
334
+ continue
335
+
336
+ if installed_version_satisfies(spec):
337
+ installed_ver = get_installed_version(spec)
338
+ console.print(f" [dim]{server.name}: {spec} already installed (v{installed_ver})[/dim]")
339
+ continue
340
+
341
+ console.print(f" [cyan]{server.name} requires pip package: {spec}[/cyan]")
342
+ if server.description:
343
+ console.print(f" [dim]{server.description}[/dim]")
344
+
345
+ if not Confirm.ask(f" Install {spec}?", default=True):
346
+ console.print(f" [yellow]Skipped pip install for {server.name}[/yellow]")
347
+ failed_servers.add(server.name)
348
+ continue
349
+
350
+ with console.status(f" Installing {spec}..."):
351
+ success, message = install_pip_package(spec)
352
+
353
+ if success:
354
+ console.print(f" [green]{message}[/green]")
355
+ else:
356
+ console.print(f" [red]{message}[/red]")
357
+ console.print(f" [yellow]MCP server {server.name} may not work without {spec}[/yellow]")
358
+ failed_servers.add(server.name)
359
+
360
+ return failed_servers
@@ -130,6 +130,11 @@ def install(
130
130
  "-p",
131
131
  help="Target project directory (default: current directory)",
132
132
  ),
133
+ skip_pip: bool = typer.Option(
134
+ False,
135
+ "--skip-pip",
136
+ help="Skip pip package installations for MCP servers",
137
+ ),
133
138
  ) -> None:
134
139
  """Install a package into the current project.
135
140
 
@@ -151,6 +156,9 @@ def install(
151
156
 
152
157
  # Skip conflicts
153
158
  devsync install ./package --conflict skip
159
+
160
+ # Skip pip installations
161
+ devsync install ./package --skip-pip
154
162
  """
155
163
  from devsync.cli.install_v2 import install_v2_command
156
164
 
@@ -160,6 +168,7 @@ def install(
160
168
  no_ai=no_ai,
161
169
  conflict=conflict,
162
170
  project_dir=project_dir,
171
+ skip_pip=skip_pip,
163
172
  )
164
173
  raise typer.Exit(code=exit_code)
165
174
 
@@ -58,6 +58,7 @@ class DetectedMCPServer:
58
58
  config: dict
59
59
  source: str
60
60
  env_vars: list[str] = field(default_factory=list)
61
+ pip_package: Optional[str] = None
61
62
 
62
63
 
63
64
  @dataclass
@@ -424,6 +425,10 @@ class ComponentDetector:
424
425
  mcp_servers = config_data.get("mcpServers", {})
425
426
  for server_name, server_config in mcp_servers.items():
426
427
  env_vars = list(server_config.get("env", {}).keys())
428
+ pip_package = self._resolve_pip_package(
429
+ server_config.get("command", ""),
430
+ server_config.get("args", []),
431
+ )
427
432
  servers.append(
428
433
  DetectedMCPServer(
429
434
  name=server_name,
@@ -431,6 +436,7 @@ class ComponentDetector:
431
436
  config=server_config,
432
437
  source=config_location,
433
438
  env_vars=env_vars,
439
+ pip_package=pip_package,
434
440
  )
435
441
  )
436
442
  except json.JSONDecodeError as e:
@@ -445,6 +451,10 @@ class ComponentDetector:
445
451
  with open(file_path, "r", encoding="utf-8") as f:
446
452
  server_config = json.load(f)
447
453
  env_vars = list(server_config.get("env", {}).keys())
454
+ pip_package = self._resolve_pip_package(
455
+ server_config.get("command", ""),
456
+ server_config.get("args", []),
457
+ )
448
458
  servers.append(
449
459
  DetectedMCPServer(
450
460
  name=file_path.stem,
@@ -452,6 +462,7 @@ class ComponentDetector:
452
462
  config=server_config,
453
463
  source=str(file_path.relative_to(self.project_root)),
454
464
  env_vars=env_vars,
465
+ pip_package=pip_package,
455
466
  )
456
467
  )
457
468
  except Exception as e:
@@ -459,6 +470,24 @@ class ComponentDetector:
459
470
 
460
471
  return servers
461
472
 
473
+ def _resolve_pip_package(self, command: str, args: list[str]) -> Optional[str]:
474
+ """Attempt to resolve a pip package from an MCP server command.
475
+
476
+ Non-fatal: returns None on any failure.
477
+
478
+ Args:
479
+ command: Server executable command.
480
+ args: Server command arguments.
481
+
482
+ Returns:
483
+ Pip package name or None.
484
+ """
485
+ if not command:
486
+ return None
487
+ from devsync.core.pip_utils import resolve_pip_package_for_command
488
+
489
+ return resolve_pip_package_for_command(command, args)
490
+
462
491
  def _detect_hooks(self) -> list[DetectedHook]:
463
492
  """Detect hook scripts.
464
493
 
@@ -73,7 +73,7 @@ class PracticeExtractor:
73
73
  configs = []
74
74
  for server in getattr(detection, "mcp_servers", []):
75
75
  config: dict = {}
76
- for attr in ("name", "command", "args", "env"):
76
+ for attr in ("name", "command", "args", "env", "pip_package"):
77
77
  val = getattr(server, attr, None)
78
78
  if val is not None:
79
79
  config[attr] = val
@@ -140,6 +140,7 @@ class PracticeExtractor:
140
140
  description=f"MCP server: {name}",
141
141
  command=config.get("command", ""),
142
142
  args=config.get("args", []),
143
+ pip_package=config.get("pip_package"),
143
144
  )
144
145
  )
145
146
 
@@ -0,0 +1,314 @@
1
+ """Pip package validation, detection, and installation utilities.
2
+
3
+ All pip-related logic is isolated here for security audit. Functions validate
4
+ inputs, detect installed packages, resolve commands to pip packages, and
5
+ install packages with comprehensive error handling.
6
+ """
7
+
8
+ import importlib.metadata
9
+ import logging
10
+ import os
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from typing import Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Allowlist pattern for pip package specs: name, name>=1.0, name[extra]==2.0, etc.
20
+ # Rejects URLs, paths, and shell metacharacters.
21
+ _PIP_SPEC_PATTERN = re.compile(
22
+ r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?" # package name
23
+ r"(\[[A-Za-z0-9,._-]+\])?" # optional extras
24
+ r"([<>=!~]+[A-Za-z0-9.*]+)?" # optional version constraint
25
+ r"$"
26
+ )
27
+
28
+ _DANGEROUS_CHARS = set(";|&$`{}()\n\r")
29
+
30
+
31
+ def validate_pip_spec(spec: str) -> bool:
32
+ """Validate a pip package specifier against an allowlist.
33
+
34
+ Accepts: 'name', 'name>=1.0', 'name[extra]==2.0'
35
+ Rejects: URLs, file paths, shell metacharacters, empty strings.
36
+
37
+ Args:
38
+ spec: Pip package specifier string.
39
+
40
+ Returns:
41
+ True if the spec is valid and safe.
42
+ """
43
+ if not spec or not spec.strip():
44
+ return False
45
+
46
+ spec = spec.strip()
47
+
48
+ if any(c in spec for c in _DANGEROUS_CHARS):
49
+ return False
50
+
51
+ if "://" in spec or spec.startswith(("git+", "file:", "/", "\\", ".")):
52
+ return False
53
+
54
+ return bool(_PIP_SPEC_PATTERN.match(spec))
55
+
56
+
57
+ def _extract_base_name(spec: str) -> str:
58
+ """Extract the base package name from a pip spec, stripping version/extras."""
59
+ name = re.split(r"[<>=!~\[]", spec.strip())[0]
60
+ return name
61
+
62
+
63
+ def is_pip_installed(package_name: str) -> bool:
64
+ """Check if a pip package is installed.
65
+
66
+ Args:
67
+ package_name: Package name (version constraints are stripped).
68
+
69
+ Returns:
70
+ True if the package is installed.
71
+ """
72
+ base = _extract_base_name(package_name)
73
+ try:
74
+ importlib.metadata.version(base)
75
+ return True
76
+ except importlib.metadata.PackageNotFoundError:
77
+ return False
78
+
79
+
80
+ def get_installed_version(package_name: str) -> Optional[str]:
81
+ """Get the installed version of a pip package.
82
+
83
+ Args:
84
+ package_name: Package name (version constraints are stripped).
85
+
86
+ Returns:
87
+ Version string or None if not installed.
88
+ """
89
+ base = _extract_base_name(package_name)
90
+ try:
91
+ return importlib.metadata.version(base)
92
+ except importlib.metadata.PackageNotFoundError:
93
+ return None
94
+
95
+
96
+ def installed_version_satisfies(spec: str) -> bool:
97
+ """Check if the installed version of a package satisfies the spec.
98
+
99
+ Args:
100
+ spec: Pip package specifier (e.g., 'mcp-server>=1.0').
101
+
102
+ Returns:
103
+ True if the package is installed and satisfies the version constraint.
104
+ False if not installed or version doesn't satisfy.
105
+ """
106
+ base = _extract_base_name(spec)
107
+ installed = get_installed_version(base)
108
+ if installed is None:
109
+ return False
110
+
111
+ # Extract version constraint from spec
112
+ constraint_match = re.search(r"([<>=!~]+.+)$", spec.strip())
113
+ if not constraint_match:
114
+ return True
115
+
116
+ constraint = constraint_match.group(1)
117
+
118
+ try:
119
+ from packaging.specifiers import SpecifierSet
120
+ from packaging.version import Version
121
+
122
+ specifier = SpecifierSet(constraint)
123
+ return Version(installed) in specifier
124
+ except ImportError:
125
+ # packaging not available — fall back to simple presence check
126
+ logger.debug("packaging library not available, skipping version constraint check")
127
+ return True
128
+ except Exception:
129
+ # Invalid version/spec — assume satisfied to avoid blocking install
130
+ return True
131
+
132
+
133
+ def resolve_pip_package_for_command(command: str, args: list[str]) -> Optional[str]:
134
+ """Resolve a command/args pair to a pip package name if possible.
135
+
136
+ Patterns detected:
137
+ - command="python" (or python3), args contains "-m", "module_name"
138
+ - command="uvx", first arg is package name
139
+ - command is a console_script entry point
140
+
141
+ Args:
142
+ command: The executable command.
143
+ args: Command arguments.
144
+
145
+ Returns:
146
+ Pip package name or None if unrecognized.
147
+ """
148
+ try:
149
+ return _resolve_pip_package_for_command_inner(command, args)
150
+ except Exception as e:
151
+ logger.warning("Failed to resolve pip package for command %s: %s", command, e)
152
+ return None
153
+
154
+
155
+ def _resolve_pip_package_for_command_inner(command: str, args: list[str]) -> Optional[str]:
156
+ """Inner implementation of resolve_pip_package_for_command."""
157
+ cmd_basename = os.path.basename(command)
158
+
159
+ # Pattern 1: python -m module_name
160
+ if cmd_basename == "python" or cmd_basename == "python3" or re.match(r"python3\.\d+$", cmd_basename):
161
+ if "-m" in args:
162
+ m_idx = args.index("-m")
163
+ if m_idx + 1 < len(args):
164
+ module_name = args[m_idx + 1]
165
+ return _find_distribution_for_module(module_name)
166
+
167
+ # Pattern 2: uvx package_name
168
+ if cmd_basename == "uvx":
169
+ if args:
170
+ pkg_name = args[0]
171
+ if not pkg_name.startswith("-") and validate_pip_spec(pkg_name):
172
+ return pkg_name
173
+
174
+ # Pattern 3: command is a console_script entry point
175
+ dist = _find_distribution_for_script(cmd_basename)
176
+ if dist:
177
+ return dist
178
+
179
+ return None
180
+
181
+
182
+ def _find_distribution_for_module(module_name: str) -> Optional[str]:
183
+ """Find the distribution that provides a given top-level module.
184
+
185
+ Compatible with Python 3.10+ (packages_distributions() is 3.11+).
186
+ """
187
+ # Try packages_distributions() (3.11+)
188
+ try:
189
+ pkg_dists = importlib.metadata.packages_distributions() # type: ignore[attr-defined]
190
+ dists = pkg_dists.get(module_name)
191
+ if dists:
192
+ return dists[0]
193
+ except AttributeError:
194
+ pass
195
+
196
+ # Fallback for 3.10: iterate distributions
197
+ for dist in importlib.metadata.distributions():
198
+ top_level = dist.read_text("top_level.txt")
199
+ if top_level:
200
+ modules = [m.strip() for m in top_level.strip().split("\n") if m.strip()]
201
+ if module_name in modules:
202
+ return dist.metadata["Name"]
203
+
204
+ return None
205
+
206
+
207
+ def _find_distribution_for_script(script_name: str) -> Optional[str]:
208
+ """Find the distribution that provides a given console_script entry point.
209
+
210
+ Compatible with Python 3.10+ (entry_points() API varies by version).
211
+ """
212
+ try:
213
+ eps = importlib.metadata.entry_points()
214
+ # Python 3.12+: eps.select()
215
+ if hasattr(eps, "select"):
216
+ console_scripts = eps.select(group="console_scripts") # type: ignore[union-attr]
217
+ elif isinstance(eps, dict):
218
+ # Python 3.10-3.11: eps is a dict
219
+ console_scripts = eps.get("console_scripts", []) # type: ignore[arg-type]
220
+ else:
221
+ console_scripts = []
222
+
223
+ for ep in console_scripts:
224
+ if ep.name == script_name:
225
+ # ep.dist may not exist on all versions
226
+ if hasattr(ep, "dist") and ep.dist is not None:
227
+ return ep.dist.metadata["Name"]
228
+ continue
229
+ except Exception:
230
+ pass
231
+
232
+ return None
233
+
234
+
235
+ def find_pip_executable() -> Optional[str]:
236
+ """Find a usable way to run pip.
237
+
238
+ Prefers `sys.executable -m pip` (respects current venv),
239
+ falls back to `shutil.which("pip")`.
240
+
241
+ Returns:
242
+ Python executable path (for `-m pip` usage) or standalone pip path,
243
+ or None if pip is unavailable.
244
+ """
245
+ try:
246
+ result = subprocess.run(
247
+ [sys.executable, "-m", "pip", "--version"],
248
+ capture_output=True,
249
+ timeout=10,
250
+ check=False,
251
+ )
252
+ if result.returncode == 0:
253
+ return sys.executable
254
+ except (OSError, subprocess.TimeoutExpired):
255
+ pass
256
+
257
+ pip_path = shutil.which("pip")
258
+ if pip_path:
259
+ return pip_path
260
+
261
+ return None
262
+
263
+
264
+ def install_pip_package(spec: str, timeout: int = 120) -> tuple[bool, str]:
265
+ """Install a pip package with comprehensive error handling.
266
+
267
+ Validates the spec first, then runs pip install in a subprocess.
268
+
269
+ Args:
270
+ spec: Pip package specifier (e.g., 'mcp-server-github>=1.0').
271
+ timeout: Maximum seconds to wait for install.
272
+
273
+ Returns:
274
+ Tuple of (success, message).
275
+ """
276
+ if not validate_pip_spec(spec):
277
+ return (False, f"Invalid pip package spec: {spec}")
278
+
279
+ pip_exe = find_pip_executable()
280
+ if not pip_exe:
281
+ return (False, "pip is not available. Install pip or use a virtual environment.")
282
+
283
+ # Build command: either `python -m pip install` or `pip install`
284
+ if pip_exe == sys.executable:
285
+ cmd = [sys.executable, "-m", "pip", "install", spec]
286
+ else:
287
+ cmd = [pip_exe, "install", spec]
288
+
289
+ try:
290
+ result = subprocess.run(
291
+ cmd,
292
+ capture_output=True,
293
+ text=True,
294
+ timeout=timeout,
295
+ check=False,
296
+ )
297
+
298
+ if result.returncode == 0:
299
+ return (True, f"Successfully installed {spec}")
300
+
301
+ stderr = result.stderr.lower()
302
+ if "no matching distribution" in stderr:
303
+ return (False, f"Package not found: {spec}")
304
+ if "could not find a version" in stderr:
305
+ return (False, f"No compatible version found for {spec}")
306
+ if "permission denied" in stderr or "permissionerror" in stderr:
307
+ return (False, f"Permission denied installing {spec}. Try using a virtual environment.")
308
+
309
+ return (False, f"pip install failed for {spec} (exit code {result.returncode})")
310
+
311
+ except subprocess.TimeoutExpired:
312
+ return (False, f"pip install timed out after {timeout}s for {spec}")
313
+ except OSError as e:
314
+ return (False, f"Failed to run pip: {e}")
@@ -3,6 +3,8 @@
3
3
  from dataclasses import dataclass, field
4
4
  from typing import Optional
5
5
 
6
+ from devsync.core.pip_utils import validate_pip_spec
7
+
6
8
 
7
9
  @dataclass
8
10
  class CredentialSpec:
@@ -135,6 +137,7 @@ class MCPDeclaration:
135
137
  args: list[str] = field(default_factory=list)
136
138
  env_vars: dict[str, str] = field(default_factory=dict)
137
139
  credentials: list[CredentialSpec] = field(default_factory=list)
140
+ pip_package: Optional[str] = None
138
141
 
139
142
  def __post_init__(self) -> None:
140
143
  if not self.name:
@@ -143,6 +146,9 @@ class MCPDeclaration:
143
146
  raise ValueError("MCPDeclaration description cannot be empty")
144
147
  if self.protocol not in ("stdio", "sse"):
145
148
  raise ValueError(f"MCPDeclaration protocol must be 'stdio' or 'sse', got '{self.protocol}'")
149
+ if self.pip_package is not None:
150
+ if not validate_pip_spec(self.pip_package):
151
+ raise ValueError(f"MCPDeclaration pip_package is not a valid pip spec: '{self.pip_package}'")
146
152
 
147
153
  def to_dict(self) -> dict:
148
154
  result: dict = {
@@ -158,6 +164,8 @@ class MCPDeclaration:
158
164
  result["env_vars"] = self.env_vars
159
165
  if self.credentials:
160
166
  result["credentials"] = [c.to_dict() for c in self.credentials]
167
+ if self.pip_package is not None:
168
+ result["pip_package"] = self.pip_package
161
169
  return result
162
170
 
163
171
  @classmethod
@@ -171,4 +179,5 @@ class MCPDeclaration:
171
179
  args=data.get("args", []),
172
180
  env_vars=data.get("env_vars", {}),
173
181
  credentials=credentials,
182
+ pip_package=data.get("pip_package"),
174
183
  )
@@ -41,6 +41,9 @@ Strip all credential VALUES but keep credential NAMES and descriptions.
41
41
  Input configuration:
42
42
  {mcp_config}
43
43
 
44
+ If the MCP server command suggests a pip-installable package (e.g., uvx, python -m),
45
+ include the pip_package field with the package name and optional version constraint.
46
+
44
47
  Respond with a JSON object:
45
48
  {{
46
49
  "name": "server-name",
@@ -55,7 +58,8 @@ Respond with a JSON object:
55
58
  "description": "what this credential is for",
56
59
  "required": true
57
60
  }}
58
- ]
61
+ ],
62
+ "pip_package": "package-name>=1.0 (if pip-installable, null otherwise)"
59
63
  }}"""
60
64
 
61
65
  ADAPT_PRACTICE_PROMPT = """\
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: devsync
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Distribute and sync dev tool configurations across teams
5
5
  Author-email: Troy Larson <troy@calvinware.com>
6
6
  License: MIT License
@@ -86,6 +86,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
86
86
  - **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
87
87
  - **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
88
88
  - **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
89
+ - **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
89
90
  - **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
90
91
  - **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
91
92
  - **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
@@ -59,6 +59,7 @@ devsync/core/models.py
59
59
  devsync/core/package_creator.py
60
60
  devsync/core/package_manifest.py
61
61
  devsync/core/package_manifest_v2.py
62
+ devsync/core/pip_utils.py
62
63
  devsync/core/practice.py
63
64
  devsync/core/repository.py
64
65
  devsync/core/secret_detector.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devsync"
7
- version = "0.12.0"
7
+ version = "0.13.0"
8
8
  description = "Distribute and sync dev tool configurations across teams"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Troy Larson", email = "troy@calvinware.com" }]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes