Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ devsync/
│ ├── mcp_credential_prompter.py # MCP credential prompting
│ ├── repository.py # Parse ai-config-kit.yaml
│ ├── git_operations.py # Git clone/pull operations
│ ├── pip_utils.py # Pip package validation, detection, installation
│ ├── checksum.py # File integrity checking
│ └── conflict_resolution.py # Handle file conflicts
├── llm/ # LLM provider abstraction (HTTP-only, no SDK deps)
Expand Down Expand Up @@ -355,6 +356,7 @@ devsync install https://github.com/company/standards
devsync install ./package --tool claude --tool cursor
devsync install ./package --no-ai
devsync install ./package --conflict skip
devsync install ./package --skip-pip

# List installed packages
devsync list
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ No API key? DevSync works without one -- it falls back to file-copy mode. Add `-
- **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations
- **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging
- **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more
- **MCP server dependencies** -- auto-detects pip-installable MCP servers and prompts to install them (`--skip-pip` to skip)
- **MCP credential handling** -- prompts for credentials at install time, never stores them in repos
- **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy
- **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode
Expand Down
106 changes: 96 additions & 10 deletions devsync/cli/install_v2.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
"""V2 install command — AI-powered package installation."""

from __future__ import annotations

import shutil
import tempfile
from pathlib import Path
from typing import Optional
from typing import TYPE_CHECKING, Optional

if TYPE_CHECKING:
from devsync.core.practice import MCPDeclaration

from rich.console import Console
from rich.prompt import Confirm
Expand All @@ -26,6 +31,7 @@ def install_v2_command(
no_ai: bool = False,
conflict: str = "prompt",
project_dir: Optional[str] = None,
skip_pip: bool = False,
) -> int:
"""Install a package into the current project.

Expand All @@ -37,6 +43,7 @@ def install_v2_command(
no_ai: Disable AI-powered adaptation.
conflict: Conflict strategy ('prompt', 'skip', 'overwrite', 'rename').
project_dir: Target project directory. Defaults to cwd.
skip_pip: Skip pip package installations for MCP servers.

Returns:
Exit code (0 = success).
Expand Down Expand Up @@ -72,8 +79,8 @@ def install_v2_command(
console.print(f" Tools: {', '.join(target_tools)}")

if manifest.is_v2 and manifest.has_practices and not no_ai:
return _install_v2_ai(manifest, project_root, target_tools)
return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict)
return _install_v2_ai(manifest, project_root, target_tools, skip_pip=skip_pip)
return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict, skip_pip=skip_pip)
finally:
if cloned_tmp and cloned_tmp.exists():
shutil.rmtree(cloned_tmp, ignore_errors=True)
Expand Down Expand Up @@ -125,6 +132,7 @@ def _install_v2_ai(
manifest: PackageManifestV2,
project_root: Path,
target_tools: list[str],
skip_pip: bool = False,
) -> int:
"""Install using AI-powered adaptation."""
config = load_config()
Expand All @@ -142,7 +150,7 @@ def _install_v2_ai(
_execute_plan(plan, project_root, target_tools)

if manifest.mcp_servers:
_install_mcp_servers(manifest, project_root)
_install_mcp_servers(manifest, project_root, skip_pip=skip_pip)

console.print(f"\n[green]Installed {manifest.name} successfully.[/green]")
return 0
Expand All @@ -154,6 +162,7 @@ def _install_v2_fallback(
project_root: Path,
target_tools: list[str],
conflict: str,
skip_pip: bool = False,
) -> int:
"""Install using file-copy mode (v1 compat or --no-ai)."""
installed_count = 0
Expand Down Expand Up @@ -205,7 +214,7 @@ def _install_v2_fallback(
console.print(f" Installed: {ref.name} → {dest.relative_to(project_root)}")

if manifest.mcp_servers:
_install_mcp_servers(manifest, project_root)
_install_mcp_servers(manifest, project_root, skip_pip=skip_pip)

console.print(f"\n[green]Installed {installed_count} instructions.[/green]")
return 0
Expand Down Expand Up @@ -258,17 +267,94 @@ def _get_tool_instruction_path(tool_name: str, project_root: Path, instruction_n
return project_root / dir_name / f"{instruction_name}{ext}"


def _install_mcp_servers(manifest: PackageManifestV2, project_root: Path) -> None:
"""Install MCP server configurations with credential prompting."""
servers_with_creds = [s for s in manifest.mcp_servers if s.credentials]
def _install_mcp_servers(
manifest: PackageManifestV2,
project_root: Path,
skip_pip: bool = False,
) -> None:
"""Install MCP server configurations with pip dependencies and credential prompting."""
failed_pip_servers = _install_pip_dependencies(manifest.mcp_servers, skip_pip=skip_pip)

# Skip credential prompting for servers whose pip deps failed
eligible_servers = [s for s in manifest.mcp_servers if s.name not in failed_pip_servers]
servers_with_creds = [s for s in eligible_servers if s.credentials]
if servers_with_creds:
env_path = project_root / ".devsync" / ".env"
credentials = prompt_mcp_credentials(servers_with_creds, env_path=env_path)

for server in manifest.mcp_servers:
for server in eligible_servers:
server_creds = credentials.get(server.name, {})
build_mcp_config(server, server_creds)
console.print(f" MCP: {server.name} configured")
else:
for server in manifest.mcp_servers:
for server in eligible_servers:
console.print(f" MCP: {server.name} (no credentials needed)")


def _install_pip_dependencies(
mcp_servers: list[MCPDeclaration],
skip_pip: bool = False,
) -> set[str]:
"""Install pip package dependencies for MCP servers.

Args:
mcp_servers: List of MCPDeclaration objects.
skip_pip: If True, skip all pip installations.

Returns:
Set of server names whose pip dependency installation failed or was declined.
"""
from devsync.core.pip_utils import (
get_installed_version,
install_pip_package,
installed_version_satisfies,
validate_pip_spec,
)

failed_servers: set[str] = set()
servers_with_pip = [s for s in mcp_servers if s.pip_package]
if not servers_with_pip:
return failed_servers

console.print("\n[bold]MCP Server Dependencies[/bold]")

if skip_pip:
console.print(" [yellow]Skipping pip installations (--skip-pip)[/yellow]")
for server in servers_with_pip:
console.print(f" [dim]{server.name}: {server.pip_package} (skipped)[/dim]")
return failed_servers

for server in servers_with_pip:
spec = server.pip_package
assert spec is not None # guarded by servers_with_pip filter

if not validate_pip_spec(spec):
console.print(f" [red]Invalid package spec for {server.name}: {spec}[/red]")
failed_servers.add(server.name)
continue

if installed_version_satisfies(spec):
installed_ver = get_installed_version(spec)
console.print(f" [dim]{server.name}: {spec} already installed (v{installed_ver})[/dim]")
continue

console.print(f" [cyan]{server.name} requires pip package: {spec}[/cyan]")
if server.description:
console.print(f" [dim]{server.description}[/dim]")

if not Confirm.ask(f" Install {spec}?", default=True):
console.print(f" [yellow]Skipped pip install for {server.name}[/yellow]")
failed_servers.add(server.name)
continue

with console.status(f" Installing {spec}..."):
success, message = install_pip_package(spec)

if success:
console.print(f" [green]{message}[/green]")
else:
console.print(f" [red]{message}[/red]")
console.print(f" [yellow]MCP server {server.name} may not work without {spec}[/yellow]")
failed_servers.add(server.name)

return failed_servers
9 changes: 9 additions & 0 deletions devsync/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ def install(
"-p",
help="Target project directory (default: current directory)",
),
skip_pip: bool = typer.Option(
False,
"--skip-pip",
help="Skip pip package installations for MCP servers",
),
) -> None:
"""Install a package into the current project.

Expand All @@ -151,6 +156,9 @@ def install(

# Skip conflicts
devsync install ./package --conflict skip

# Skip pip installations
devsync install ./package --skip-pip
"""
from devsync.cli.install_v2 import install_v2_command

Expand All @@ -160,6 +168,7 @@ def install(
no_ai=no_ai,
conflict=conflict,
project_dir=project_dir,
skip_pip=skip_pip,
)
raise typer.Exit(code=exit_code)

Expand Down
29 changes: 29 additions & 0 deletions devsync/core/component_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class DetectedMCPServer:
config: dict
source: str
env_vars: list[str] = field(default_factory=list)
pip_package: Optional[str] = None


@dataclass
Expand Down Expand Up @@ -424,13 +425,18 @@ def _detect_mcp_servers(self) -> list[DetectedMCPServer]:
mcp_servers = config_data.get("mcpServers", {})
for server_name, server_config in mcp_servers.items():
env_vars = list(server_config.get("env", {}).keys())
pip_package = self._resolve_pip_package(
server_config.get("command", ""),
server_config.get("args", []),
)
servers.append(
DetectedMCPServer(
name=server_name,
file_path=config_path,
config=server_config,
source=config_location,
env_vars=env_vars,
pip_package=pip_package,
)
)
except json.JSONDecodeError as e:
Expand All @@ -445,20 +451,43 @@ def _detect_mcp_servers(self) -> list[DetectedMCPServer]:
with open(file_path, "r", encoding="utf-8") as f:
server_config = json.load(f)
env_vars = list(server_config.get("env", {}).keys())
pip_package = self._resolve_pip_package(
server_config.get("command", ""),
server_config.get("args", []),
)
servers.append(
DetectedMCPServer(
name=file_path.stem,
file_path=file_path,
config=server_config,
source=str(file_path.relative_to(self.project_root)),
env_vars=env_vars,
pip_package=pip_package,
)
)
except Exception as e:
logger.warning(f"Failed to read MCP config {file_path}: {e}")

return servers

def _resolve_pip_package(self, command: str, args: list[str]) -> Optional[str]:
"""Attempt to resolve a pip package from an MCP server command.

Non-fatal: returns None on any failure.

Args:
command: Server executable command.
args: Server command arguments.

Returns:
Pip package name or None.
"""
if not command:
return None
from devsync.core.pip_utils import resolve_pip_package_for_command

return resolve_pip_package_for_command(command, args)

def _detect_hooks(self) -> list[DetectedHook]:
"""Detect hook scripts.

Expand Down
3 changes: 2 additions & 1 deletion devsync/core/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def _read_mcp_configs(self, detection: object) -> list[dict]:
configs = []
for server in getattr(detection, "mcp_servers", []):
config: dict = {}
for attr in ("name", "command", "args", "env"):
for attr in ("name", "command", "args", "env", "pip_package"):
val = getattr(server, attr, None)
if val is not None:
config[attr] = val
Expand Down Expand Up @@ -140,6 +140,7 @@ def _extract_without_ai(self, files: dict[str, str], mcp_configs: list[dict]) ->
description=f"MCP server: {name}",
command=config.get("command", ""),
args=config.get("args", []),
pip_package=config.get("pip_package"),
)
)

Expand Down
Loading
Loading