diff --git a/CLAUDE.md b/CLAUDE.md index 783887a..25237c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ devsync/ │ ├── 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 +│ ├── conflict_resolution.py # Handle file conflicts +│ ├── component_detector.py # Multi-tool component detection and filtering +│ └── capability_registry.py # AI tool capability metadata ├── llm/ # LLM provider abstraction (HTTP-only, no SDK deps) │ ├── provider.py # Abstract LLMProvider, LLMResponse, resolve_provider() │ ├── anthropic.py # Anthropic Claude (HTTP via httpx) @@ -349,6 +351,10 @@ devsync tools devsync extract devsync extract --no-ai # File-copy mode devsync extract --output ./pkg --name team-standards +devsync extract --tool cursor # Extract from Cursor only +devsync extract --component mcp # Extract MCP servers only +devsync extract --scope all # Include global configs +devsync extract --tool claude --component rules # Combine filters # Install a package devsync install ./team-standards diff --git a/VISION.md b/VISION.md index 80dced3..43b64ef 100644 --- a/VISION.md +++ b/VISION.md @@ -27,7 +27,7 @@ These principles guide every feature decision. New features must align with at l `pip install devsync && devsync tools` — that's it. No Docker, no external services, no accounts required. Download from any Git repo, install with one command. If a feature requires infrastructure to work, it doesn't belong. ### 2. IDE-agnostic -22+ tools, one package format, no lock-in. DevSync translates configurations to each tool's native format. Adding a new AI tool should be a single file implementing the `AITool` base class. Users should never have to think about tool-specific file formats. +22+ tools, one package format, no lock-in. DevSync extracts from and installs to each tool's native format. Both extraction and installation must work across all supported tools — a user with only Cursor configs should be able to extract and share them just as easily as a Claude Code user. Adding a new AI tool should be a single file implementing the `AITool` base class. Users should never have to think about tool-specific file formats. ### 3. Git as distribution No central server, no registry, no accounts. Any Git repository can be an instruction source. Version control is the distribution mechanism. If it works with `git clone`, it works with DevSync. @@ -43,9 +43,10 @@ MCP protocol for server configs, conventional markdown for instructions, YAML ma ## What's In Scope -- **AI-powered extraction**: Read a project's rules, MCP configs, and commands to produce abstract practice declarations +- **AI-powered extraction**: Read a project's rules, MCP configs, and commands to produce abstract practice declarations — from any supported tool, not just one - **AI-powered installation**: Adapt incoming practices to the recipient's existing setup with intelligent merging -- **Multi-tool support**: Translate configs to 23+ AI coding assistants +- **Multi-tool support**: Extract from and install to 23+ AI coding assistants. Both sides of the pipeline must be tool-agnostic +- **Selective packaging**: Filter extractions by tool, component type (rules, MCP servers, commands, hooks), and scope (project vs global) - **Package system**: v2 practice-based packages and v1 file-copy backward compatibility - **MCP credential handling**: Prompt for credentials at install time, never store in repos - **Project tracking**: Per-project installation records diff --git a/devsync/ai_tools/capability_registry.py b/devsync/ai_tools/capability_registry.py index 9505569..6168c17 100644 --- a/devsync/ai_tools/capability_registry.py +++ b/devsync/ai_tools/capability_registry.py @@ -28,6 +28,7 @@ class IDECapability: skills_directory: str | None = None # Claude skills workflows_directory: str | None = None # Windsurf workflows memory_file_name: str | None = None # CLAUDE.md + mcp_servers_json_key: str = "mcpServers" # JSON key for MCP servers in config files notes: str = "" def supports_component(self, component_type: ComponentType) -> bool: @@ -494,6 +495,7 @@ def supports_component(self, component_type: ComponentType) -> bool: mcp_project_config_path=".vscode/mcp.json", # Workspace-level MCP config hooks_directory=None, # Hooks not supported commands_directory=None, # Commands not supported + mcp_servers_json_key="servers", # VS Code uses "servers" not "mcpServers" notes=( "GitHub Copilot uses .github/copilot-instructions.md (main) and " ".github/instructions/**/*.instructions.md (file-specific with globs). " diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index 99bb1e8..b17e46d 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -1,12 +1,21 @@ """Extract command — reads project configs and produces a shareable package.""" import shutil +import warnings +from collections import Counter from pathlib import Path from typing import Optional from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn - +from rich.table import Table + +from devsync.core.component_detector import ( + COMPONENT_TYPE_MAP, + ComponentDetector, + DetectionResult, + filter_detection_result, +) from devsync.core.extractor import PracticeExtractor from devsync.core.package_manifest_v2 import PackageManifestV2, detect_manifest_format, parse_manifest from devsync.llm.config import load_config @@ -14,6 +23,89 @@ console = Console() +# Map DetectionResult field names back to user-facing component names +_FIELD_TO_LABEL: dict[str, str] = { + "instructions": "Rules", + "mcp_servers": "MCP", + "hooks": "Hooks", + "commands": "Commands", + "skills": "Skills", + "workflows": "Workflows", + "memory_files": "Memory", + "resources": "Resources", +} + + +def _get_detection_rows(detection: DetectionResult) -> list[tuple[str, str, int]]: + """Build (component_label, source_tool, count) rows from detection. + + Groups by component type + source tool for a concise table. + + Returns: + List of (label, source, count) tuples, sorted by label then source. + """ + rows: list[tuple[str, str, int]] = [] + + for field_name, label in _FIELD_TO_LABEL.items(): + items = getattr(detection, field_name, []) + if not items: + continue + + source_counts: Counter[str] = Counter() + for item in items: + source = getattr(item, "source_tool", "") or getattr(item, "source_ide", "") or "project" + source_counts[source] += 1 + + for source, count in sorted(source_counts.items()): + rows.append((label, source, count)) + + return rows + + +def _display_detection_summary(detection: DetectionResult) -> None: + """Display a Rich table summarizing detected components.""" + rows = _get_detection_rows(detection) + + if not rows: + return + + table = Table(title="Detected Components", show_header=True, header_style="bold cyan") + table.add_column("Component", style="cyan", no_wrap=True) + table.add_column("Source", style="green") + table.add_column("Count", justify="right") + + for label, source, count in rows: + table.add_row(label, source, str(count)) + + console.print() + console.print(table) + + tool_sources = {source for _, source, _ in rows if source not in ("project", "devsync")} + tool_count = len(tool_sources) if tool_sources else 1 + console.print(f"\n Total: {detection.total_count} components from {tool_count} tool(s)") + + +def _display_zero_result_warning( + tool: Optional[list[str]] = None, + component: Optional[list[str]] = None, + include_global: bool = False, +) -> None: + """Display a helpful warning when filters match no components.""" + console.print("\n[yellow]No components found matching your filters.[/yellow]") + + active_filters = [] + if tool: + active_filters.append(f"--tool {' --tool '.join(tool)}") + if component: + active_filters.append(f"--component {' --component '.join(component)}") + if active_filters: + console.print(f"\n Active: {' '.join(active_filters)}") + + console.print("\n Suggestions:") + console.print(" - Run [cyan]devsync extract --dry-run[/cyan] without filters to see all available components") + if not include_global: + console.print(" - Try [cyan]--include-global[/cyan] to include home directory configs") + def extract_command( output: Optional[str] = None, @@ -21,6 +113,11 @@ def extract_command( no_ai: bool = False, project_dir: Optional[str] = None, upgrade: Optional[str] = None, + tool: Optional[list[str]] = None, + component: Optional[list[str]] = None, + scope: str = "project", + dry_run: bool = False, + include_global: bool = False, ) -> int: """Extract practices from the current project into a shareable package. @@ -30,6 +127,11 @@ def extract_command( no_ai: Force file-copy mode (no LLM calls). project_dir: Project directory to extract from. Defaults to cwd. upgrade: Path to a v1 package to convert to v2 format. + tool: Only extract from these AI tool(s). + component: Only extract these component types. + scope: Detection scope — project, global, or all. Deprecated; use include_global. + dry_run: Show what would be extracted without writing files. + include_global: Include home directory / global configs. Returns: Exit code (0 = success). @@ -37,11 +139,70 @@ def extract_command( if upgrade: return _upgrade_v1_package(upgrade, output=output, name=name, no_ai=no_ai) + # Resolve effective scope: --include-global takes precedence + effective_scope = "project" + if include_global: + effective_scope = "all" + elif scope != "project": + warnings.warn( + "--scope is deprecated, use --include-global instead", + DeprecationWarning, + stacklevel=2, + ) + console.print("[yellow]--scope is deprecated. Use --include-global instead.[/yellow]") + effective_scope = scope + + # Validate scope + if effective_scope not in ("project", "global", "all"): + console.print(f"[red]Invalid scope: {effective_scope}. Must be project, global, or all.[/red]") + return 1 + + # Validate tool names + if tool: + from devsync.ai_tools.detector import AIToolDetector + + detector = AIToolDetector() + for t in tool: + if not detector.validate_tool_name(t): + console.print(f"[red]Unknown tool: {t}[/red]") + console.print(f"Supported tools: {', '.join(detector.get_tool_names())}") + return 1 + + # Validate component names + if component: + for c in component: + if c.lower() not in COMPONENT_TYPE_MAP: + valid = sorted(set(COMPONENT_TYPE_MAP.keys())) + console.print(f"[red]Unknown component type: {c}[/red]") + console.print(f"Valid types: {', '.join(valid)}") + return 1 + project_path = Path(project_dir) if project_dir else Path.cwd() if not project_path.is_dir(): console.print(f"[red]Not a directory: {project_path}[/red]") return 1 + # Phase 1: Detection + filtering + comp_detector = ComponentDetector(project_path, scope=effective_scope, tool_filter=tool) + detection = comp_detector.detect_all() + + if component: + detection = filter_detection_result(detection, component_filter=component) + + # Zero-result handling + if detection.total_count == 0: + _display_zero_result_warning(tool=tool, component=component, include_global=include_global) + return 0 + + # Display detection summary + _display_detection_summary(detection) + + # Dry-run: stop here + if dry_run: + console.print("\n[dim]Dry run — no files written.[/dim]") + return 0 + + # Phase 2: Extraction package_name = name or project_path.name output_path = Path(output) if output else project_path / "devsync-package" @@ -63,8 +224,8 @@ def extract_command( TextColumn("[progress.description]{task.description}"), console=console, ) as progress: - task = progress.add_task("Scanning project...", total=None) - result = extractor.extract(project_path) + task = progress.add_task("Extracting practices...", total=None) + result = extractor.extract(project_path, detection=detection) progress.update(task, description="Building package...") output_path.mkdir(parents=True, exist_ok=True) @@ -96,12 +257,13 @@ def extract_command( manifest_path = output_path / "devsync-package.yaml" manifest_path.write_text(manifest.to_yaml()) + # Enhanced output mode = "[green]AI-powered[/green]" if result.ai_powered else "[yellow]file-copy[/yellow]" - console.print(f"\nExtracted ({mode}):") - console.print(f" Practices: {len(result.practices)}") + console.print(f"\nExtraction complete ({mode})") + console.print(f" Practices generated: {len(result.practices)}") console.print(f" MCP servers: {len(result.mcp_servers)}") console.print(f" Source files: {len(result.source_files)}") - console.print(f"\nPackage written to: [cyan]{output_path}[/cyan]") + console.print(f"\n Package written to: [cyan]{output_path}[/cyan]") return 0 diff --git a/devsync/cli/main.py b/devsync/cli/main.py index ceed233..f69cfaa 100644 --- a/devsync/cli/main.py +++ b/devsync/cli/main.py @@ -30,13 +30,20 @@ def setup() -> None: @app.command() -def tools() -> None: +def tools( + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Show capabilities and valid filter names", + ), +) -> None: """Show detected AI coding tools. Display which AI coding tools are installed on your system and where their configuration directories are located. """ - exit_code = show_tools() + exit_code = show_tools(verbose=verbose) raise typer.Exit(code=exit_code) @@ -70,11 +77,41 @@ def extract( "--upgrade", help="Convert a v1 package to v2 format", ), + tool: Optional[list[str]] = typer.Option( + None, + "--tool", + "-t", + help="Only extract from specific AI tool(s). Repeatable.", + ), + component: Optional[list[str]] = typer.Option( + None, + "--component", + "-c", + help="Component types to extract: rules, mcp, hooks, commands, skills, workflows, memory. Repeatable.", + ), + scope: str = typer.Option( + "project", + "--scope", + "-s", + help="(Deprecated) Detection scope: project, global, or all. Use --include-global instead.", + hidden=True, + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show detected components without writing files or calling the LLM", + ), + include_global: bool = typer.Option( + False, + "--include-global", + help="Include home directory / global configs in extraction", + ), ) -> None: """Extract practices from a project into a shareable package. - Reads your project's AI tool configs (rules, MCP servers, hooks, commands) - and produces a devsync-package.yaml with abstract practice declarations. + Reads your project's AI tool configs (rules, MCP servers, hooks, commands, + skills, workflows, memory files, resources) and produces a devsync-package.yaml + with abstract practice declarations. Examples: # AI-powered extraction @@ -86,6 +123,21 @@ def extract( # Custom output and name devsync extract --output ./my-package --name team-standards + # Extract only from Cursor + devsync extract --tool cursor + + # Extract only MCP configs + devsync extract --component mcp + + # Preview what would be extracted (no files written) + devsync extract --dry-run + + # Include home directory / global configs + devsync extract --include-global + + # Combine filters: extract only rules and hooks from Claude Code + devsync extract --tool claude --component rules --component hooks + # Upgrade v1 package to v2 devsync extract --upgrade ./old-package """ @@ -97,6 +149,11 @@ def extract( no_ai=no_ai, project_dir=project_dir, upgrade=upgrade, + tool=tool, + component=component, + scope=scope, + dry_run=dry_run, + include_global=include_global, ) raise typer.Exit(code=exit_code) diff --git a/devsync/cli/tools.py b/devsync/cli/tools.py index f6a05cf..5ac4b61 100644 --- a/devsync/cli/tools.py +++ b/devsync/cli/tools.py @@ -7,34 +7,57 @@ console = Console() +# Map ComponentType enum values to user-facing filter names +_COMPONENT_TYPE_LABELS: dict[str, str] = { + "instruction": "rules", + "mcp_server": "mcp", + "hook": "hooks", + "command": "commands", + "skill": "skills", + "workflow": "workflows", + "resource": "resources", + "memory_file": "memory", +} -def show_tools() -> int: - """ - Show detected AI coding tools. + +def show_tools(verbose: bool = False) -> int: + """Show detected AI coding tools. + + Args: + verbose: Show capabilities column and valid filter names. Returns: Exit code (0 for success) """ + from devsync.ai_tools.capability_registry import CAPABILITY_REGISTRY + detector = get_detector() - # Create table table = Table(title="AI Coding Tools", show_header=True, header_style="bold cyan") table.add_column("Tool", style="cyan", no_wrap=True) table.add_column("Status", style="green") + if verbose: + table.add_column("Capabilities", style="dim") - # Get all tools and their status for tool_type, tool in detector.tools.items(): is_installed = tool.is_installed() status = "[green]✓ Installed[/green]" if is_installed else "[red]✗ Not found[/red]" - table.add_row(tool.tool_name, status) + if verbose: + cap = CAPABILITY_REGISTRY.get(tool_type) + if cap: + labels = sorted(_COMPONENT_TYPE_LABELS.get(ct.value, ct.value) for ct in cap.supported_components) + caps_str = ", ".join(labels) + else: + caps_str = "" + table.add_row(tool.tool_name, status, caps_str) + else: + table.add_row(tool.tool_name, status) - # Display table console.print() console.print(table) console.print() - # Summary installed = detector.detect_installed_tools() if installed: tool_names = ", ".join([t.tool_name for t in installed]) @@ -43,5 +66,9 @@ def show_tools() -> int: console.print("[yellow]No AI coding tools detected[/yellow]") console.print("\nSupported tools: Cursor, GitHub Copilot, Winsurf, Claude Code") + if verbose: + all_labels = sorted(set(_COMPONENT_TYPE_LABELS.values())) + console.print(f"\nValid --component names: {', '.join(all_labels)}") + console.print() return 0 diff --git a/devsync/core/component_detector.py b/devsync/core/component_detector.py index b92103e..97e36f0 100644 --- a/devsync/core/component_detector.py +++ b/devsync/core/component_detector.py @@ -4,7 +4,7 @@ import logging from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Any, Optional from devsync.core.checksum import calculate_file_checksum from devsync.core.models import ( @@ -59,6 +59,7 @@ class DetectedMCPServer: source: str env_vars: list[str] = field(default_factory=list) pip_package: Optional[str] = None + source_tool: str = "" @dataclass @@ -76,6 +77,7 @@ class DetectedHook: file_path: Path relative_path: str hook_type: str + source_tool: str = "" @dataclass @@ -93,6 +95,7 @@ class DetectedCommand: file_path: Path relative_path: str command_type: str + source_tool: str = "" @dataclass @@ -133,6 +136,7 @@ class DetectedSkill: relative_path: str description: str = "" has_scripts: bool = False + source_tool: str = "" @dataclass @@ -144,26 +148,29 @@ class DetectedWorkflow: file_path: Absolute path to workflow file relative_path: Path relative to project root description: Workflow description + source_tool: Which tool this workflow belongs to """ name: str file_path: Path relative_path: str description: str = "" + source_tool: str = "" @dataclass class DetectedMemoryFile: - """A CLAUDE.md memory file detected in the project. + """A memory file detected in the project (e.g. CLAUDE.md, GEMINI.md). - Memory files persist context across Claude Code sessions. + Memory files persist context across AI tool sessions. Attributes: name: Identifier (path-based for subdirectory files) file_path: Absolute path to file relative_path: Path relative to project root - is_root: Whether this is the root CLAUDE.md + is_root: Whether this is the root memory file content_preview: First 100 chars of content + source_tool: Which tool this memory file belongs to """ name: str @@ -171,6 +178,7 @@ class DetectedMemoryFile: relative_path: str is_root: bool = False content_preview: str = "" + source_tool: str = "" @dataclass @@ -214,19 +222,83 @@ def total_count(self) -> int: ) +COMPONENT_TYPE_MAP: dict[str, str] = { + "rules": "instructions", + "instructions": "instructions", + "mcp": "mcp_servers", + "mcp_servers": "mcp_servers", + "hooks": "hooks", + "commands": "commands", + "skills": "skills", + "workflows": "workflows", + "memory": "memory_files", + "memory_files": "memory_files", + "resources": "resources", +} + + +def filter_detection_result( + result: DetectionResult, + tool_filter: list[str] | None = None, + component_filter: list[str] | None = None, +) -> DetectionResult: + """Filter a DetectionResult by tool and/or component type. + + Args: + result: The detection result to filter. + tool_filter: If set, only keep components from these tools. + component_filter: If set, only keep these component types + (keys from COMPONENT_TYPE_MAP). + + Returns: + A new DetectionResult with filtered lists. + """ + allowed_fields: set[str] | None = None + if component_filter: + allowed_fields = set() + for comp in component_filter: + mapped = COMPONENT_TYPE_MAP.get(comp.lower()) + if mapped: + allowed_fields.add(mapped) + + def _filter_by_tool(items: list, tool_attr: str = "source_tool") -> list: + if not tool_filter: + return items + lower_filter = {t.lower() for t in tool_filter} + return [item for item in items if getattr(item, tool_attr, "").lower() in lower_filter] + + def _get_field(field_name: str, items: list, tool_attr: str = "source_tool") -> list: + if allowed_fields is not None and field_name not in allowed_fields: + return [] + return _filter_by_tool(items, tool_attr) + + return DetectionResult( + instructions=_get_field("instructions", result.instructions, "source_ide"), + mcp_servers=_get_field("mcp_servers", result.mcp_servers), + hooks=_get_field("hooks", result.hooks), + commands=_get_field("commands", result.commands), + skills=_get_field("skills", result.skills), + workflows=_get_field("workflows", result.workflows), + memory_files=_get_field("memory_files", result.memory_files), + # Resources are tool-agnostic (.devsync/resources/), skip tool filtering + resources=result.resources if (allowed_fields is None or "resources" in allowed_fields) else [], + warnings=result.warnings, + ) + + class ComponentDetector: """Scans project directories to detect packageable components. - Detection locations: - - Instructions: .claude/rules/, .cursor/rules/, .windsurf/rules/, .github/instructions/**/* - - Main Copilot instructions: .github/copilot-instructions.md - - MCP servers: .claude/settings.local.json (mcpServers section), .devsync/mcp/ - - Hooks: .claude/hooks/ - - Commands: .claude/commands/ (legacy) - - Skills: .claude/skills/ (directories with SKILL.md) - - Workflows: .windsurf/workflows/ - - Memory files: CLAUDE.md at root and subdirectories + Uses the IDE capability registry to discover components from all supported + AI tools, not just Claude. Supports project and global scope detection. + + Detection locations are derived from CAPABILITY_REGISTRY plus: + - Instructions: INSTRUCTION_LOCATIONS (kept as-is for directory-based) + - Single-file instructions: SINGLE_INSTRUCTION_FILES + - MCP servers: Registry mcp_project_config_path / mcp_config_path + - Hooks/Commands/Skills/Workflows/Memory: Registry directories - Resources: .devsync/resources/ + - Fallback MCP: .devsync/mcp/ """ INSTRUCTION_LOCATIONS = { @@ -239,7 +311,6 @@ class ComponentDetector: ".github/instructions": "copilot", } - # Single-file instruction locations (not directories) SINGLE_INSTRUCTION_FILES = { ".github/copilot-instructions.md": "copilot", "AGENTS.md": "codex", @@ -248,28 +319,6 @@ class ComponentDetector: INSTRUCTION_EXTENSIONS = {".md", ".mdc", ".instructions.md"} - MCP_CONFIG_LOCATIONS = [ - ".claude/settings.local.json", - ] - - HOOK_LOCATIONS = [ - ".claude/hooks", - ] - - COMMAND_LOCATIONS = [ - ".claude/commands", - ] - - SKILL_LOCATIONS = [ - ".claude/skills", - ] - - WORKFLOW_LOCATIONS = [ - ".windsurf/workflows", - ] - - MEMORY_FILE_NAME = "CLAUDE.md" - RESOURCE_LOCATIONS = [ ".devsync/resources", ] @@ -277,13 +326,41 @@ class ComponentDetector: MAX_RESOURCE_SIZE = 200 * 1024 * 1024 # 200 MB WARN_RESOURCE_SIZE = 50 * 1024 * 1024 # 50 MB - def __init__(self, project_root: Path): + VALID_SCOPES = {"project", "global", "all"} + + def __init__(self, project_root: Path, scope: str = "project", tool_filter: list[str] | None = None): """Initialize detector with project root. Args: project_root: Path to project root directory + scope: Detection scope — "project", "global", or "all" + tool_filter: If set, only scan paths for these tool names + + Raises: + ValueError: If scope is not one of project, global, all """ + if scope not in self.VALID_SCOPES: + raise ValueError(f"Invalid scope: {scope!r}. Must be one of {self.VALID_SCOPES}") self.project_root = project_root.resolve() + self.scope = scope + self.tool_filter = tool_filter + + def _get_registry_entries(self) -> list[tuple[str, Any]]: + """Get registry entries filtered by tool_filter. + + Returns: + List of (tool_name, capability) tuples. + """ + from devsync.ai_tools.capability_registry import CAPABILITY_REGISTRY + + entries = [] + for _tool_type, cap in CAPABILITY_REGISTRY.items(): + tool_name = cap.tool_type.value + if self.tool_filter: + if tool_name not in {t.lower() for t in self.tool_filter}: + continue + entries.append((tool_name, cap)) + return entries def detect_all(self) -> DetectionResult: """Detect all packageable components in the project. @@ -292,31 +369,14 @@ def detect_all(self) -> DetectionResult: DetectionResult with all detected components """ result = DetectionResult() - - detected_instructions = self._detect_instructions() - result.instructions = detected_instructions - - detected_mcp = self._detect_mcp_servers() - result.mcp_servers = detected_mcp - - detected_hooks = self._detect_hooks() - result.hooks = detected_hooks - - detected_commands = self._detect_commands() - result.commands = detected_commands - - detected_skills = self._detect_skills() - result.skills = detected_skills - - detected_workflows = self._detect_workflows() - result.workflows = detected_workflows - - detected_memory_files = self._detect_memory_files() - result.memory_files = detected_memory_files - - detected_resources = self._detect_resources() - result.resources = detected_resources - + result.instructions = self._detect_instructions() + result.mcp_servers = self._detect_mcp_servers() + result.hooks = self._detect_hooks() + result.commands = self._detect_commands() + result.skills = self._detect_skills() + result.workflows = self._detect_workflows() + result.memory_files = self._detect_memory_files() + result.resources = self._detect_resources() return result def _detect_instructions(self) -> list[DetectedInstruction]: @@ -332,13 +392,14 @@ def _detect_instructions(self) -> list[DetectedInstruction]: """ instructions: list[DetectedInstruction] = [] - # Detect directory-based instructions for location, ide_name in self.INSTRUCTION_LOCATIONS.items(): + if self.tool_filter and ide_name not in {t.lower() for t in self.tool_filter}: + continue + dir_path = self.project_root / location if not dir_path.exists() or not dir_path.is_dir(): continue - # Use recursive glob for copilot to support subdirectories if ide_name == "copilot": file_iter = dir_path.rglob("*") else: @@ -348,13 +409,11 @@ def _detect_instructions(self) -> list[DetectedInstruction]: if not file_path.is_file(): continue - # Check extension suffix = file_path.suffix.lower() if suffix not in self.INSTRUCTION_EXTENSIONS: continue try: - # For copilot, include subdirectory in name if ide_name == "copilot": rel_to_dir = file_path.relative_to(dir_path) if file_path.name.endswith(".instructions.md"): @@ -379,8 +438,10 @@ def _detect_instructions(self) -> list[DetectedInstruction]: except Exception as e: logger.warning(f"Failed to read instruction {file_path}: {e}") - # Detect single-file instructions (e.g., .github/copilot-instructions.md) for file_location, ide_name in self.SINGLE_INSTRUCTION_FILES.items(): + if self.tool_filter and ide_name not in {t.lower() for t in self.tool_filter}: + continue + file_path = self.project_root / file_location if not file_path.exists() or not file_path.is_file(): continue @@ -406,44 +467,68 @@ def _detect_instructions(self) -> list[DetectedInstruction]: return instructions def _detect_mcp_servers(self) -> list[DetectedMCPServer]: - """Detect MCP server configurations. + """Detect MCP server configurations from all registered tools. + + Iterates registry-derived project and global config paths, using + each tool's mcp_servers_json_key for parsing. Returns: List of detected MCP servers """ servers: list[DetectedMCPServer] = [] + seen_paths: set[Path] = set() - for config_location in self.MCP_CONFIG_LOCATIONS: - config_path = self.project_root / config_location - if not config_path.exists(): - continue + for tool_name, cap in self._get_registry_entries(): + paths_to_check: list[tuple[Path, str]] = [] - try: - with open(config_path, "r", encoding="utf-8") as f: - config_data = json.load(f) + if self.scope in ("project", "all") and cap.mcp_project_config_path: + paths_to_check.append((self.project_root / cap.mcp_project_config_path, cap.mcp_project_config_path)) - 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, + if self.scope in ("global", "all") and cap.mcp_config_path: + expanded = Path(cap.mcp_config_path).expanduser().resolve() + if not str(expanded).startswith(str(Path.home())): + logger.warning(f"Skipping global MCP path that escapes home directory: {cap.mcp_config_path}") + continue + paths_to_check.append((expanded, cap.mcp_config_path)) + + json_key = cap.mcp_servers_json_key + + for config_path, source_label in paths_to_check: + resolved = config_path.resolve() + if resolved in seen_paths: + continue + if not config_path.exists(): + continue + seen_paths.add(resolved) + + try: + with open(config_path, "r", encoding="utf-8") as f: + config_data = json.load(f) + + mcp_servers = config_data.get(json_key, {}) + 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", []), ) - ) - except json.JSONDecodeError as e: - logger.warning(f"Invalid JSON in {config_path}: {e}") - except Exception as e: - logger.warning(f"Failed to read MCP config {config_path}: {e}") + servers.append( + DetectedMCPServer( + name=server_name, + file_path=config_path, + config=server_config, + source=source_label, + env_vars=env_vars, + pip_package=pip_package, + source_tool=tool_name, + ) + ) + except json.JSONDecodeError as e: + logger.warning(f"Invalid JSON in {config_path}: {e}") + except Exception as e: + logger.warning(f"Failed to read MCP config {config_path}: {e}") + # Fallback: .devsync/mcp/ directory (tool-agnostic) mcp_dir = self.project_root / ".devsync" / "mcp" if mcp_dir.exists() and mcp_dir.is_dir(): for file_path in mcp_dir.glob("*.json"): @@ -463,6 +548,7 @@ def _detect_mcp_servers(self) -> list[DetectedMCPServer]: source=str(file_path.relative_to(self.project_root)), env_vars=env_vars, pip_package=pip_package, + source_tool="devsync", ) ) except Exception as e: @@ -489,15 +575,18 @@ def _resolve_pip_package(self, command: str, args: list[str]) -> Optional[str]: return resolve_pip_package_for_command(command, args) def _detect_hooks(self) -> list[DetectedHook]: - """Detect hook scripts. + """Detect hook scripts from registry-derived paths. Returns: List of detected hooks """ hooks: list[DetectedHook] = [] - for location in self.HOOK_LOCATIONS: - hook_dir = self.project_root / location + for tool_name, cap in self._get_registry_entries(): + if not cap.hooks_directory: + continue + + hook_dir = self.project_root / cap.hooks_directory if not hook_dir.exists() or not hook_dir.is_dir(): continue @@ -514,6 +603,7 @@ def _detect_hooks(self) -> list[DetectedHook]: file_path=file_path, relative_path=str(file_path.relative_to(self.project_root)), hook_type=hook_type, + source_tool=tool_name, ) ) @@ -540,15 +630,18 @@ def _infer_hook_type(self, filename: str) -> str: return "Unknown" def _detect_commands(self) -> list[DetectedCommand]: - """Detect command scripts. + """Detect command scripts from registry-derived paths. Returns: List of detected commands """ commands: list[DetectedCommand] = [] - for location in self.COMMAND_LOCATIONS: - cmd_dir = self.project_root / location + for tool_name, cap in self._get_registry_entries(): + if not cap.commands_directory: + continue + + cmd_dir = self.project_root / cap.commands_directory if not cmd_dir.exists() or not cmd_dir.is_dir(): continue @@ -565,6 +658,7 @@ def _detect_commands(self) -> list[DetectedCommand]: file_path=file_path, relative_path=str(file_path.relative_to(self.project_root)), command_type=command_type, + source_tool=tool_name, ) ) @@ -631,7 +725,7 @@ def _detect_resources(self) -> list[DetectedResource]: return resources def _detect_skills(self) -> list[DetectedSkill]: - """Detect Claude skill directories. + """Detect skill directories from registry-derived paths. Skills are directories containing SKILL.md with optional supporting files. @@ -640,8 +734,11 @@ def _detect_skills(self) -> list[DetectedSkill]: """ skills: list[DetectedSkill] = [] - for location in self.SKILL_LOCATIONS: - skill_dir = self.project_root / location + for tool_name, cap in self._get_registry_entries(): + if not cap.skills_directory: + continue + + skill_dir = self.project_root / cap.skills_directory if not skill_dir.exists() or not skill_dir.is_dir(): continue @@ -651,7 +748,6 @@ def _detect_skills(self) -> list[DetectedSkill]: skill_md = item / "SKILL.md" if not skill_md.exists(): - # Also check for Skill.md (case-insensitive) skill_md_lower = item / "Skill.md" if skill_md_lower.exists(): skill_md = skill_md_lower @@ -671,6 +767,7 @@ def _detect_skills(self) -> list[DetectedSkill]: relative_path=relative_path, description=description, has_scripts=has_scripts, + source_tool=tool_name, ) ) except Exception as e: @@ -702,15 +799,18 @@ def _extract_skill_description(self, skill_md_path: Path) -> str: return "" def _detect_workflows(self) -> list[DetectedWorkflow]: - """Detect Windsurf workflow files. + """Detect workflow files from registry-derived paths. Returns: List of detected workflows """ workflows: list[DetectedWorkflow] = [] - for location in self.WORKFLOW_LOCATIONS: - workflow_dir = self.project_root / location + for tool_name, cap in self._get_registry_entries(): + if not cap.workflows_directory: + continue + + workflow_dir = self.project_root / cap.workflows_directory if not workflow_dir.exists() or not workflow_dir.is_dir(): continue @@ -731,6 +831,7 @@ def _detect_workflows(self) -> list[DetectedWorkflow]: file_path=file_path, relative_path=relative_path, description=description, + source_tool=tool_name, ) ) except Exception as e: @@ -761,66 +862,78 @@ def _extract_workflow_description(self, workflow_path: Path) -> str: return "" def _detect_memory_files(self) -> list[DetectedMemoryFile]: - """Detect CLAUDE.md memory files. + """Detect memory files from registry-derived names. - Detects CLAUDE.md at project root and in subdirectories. + Detects memory files (e.g. CLAUDE.md, GEMINI.md) at project root + and in subdirectories. Returns: List of detected memory files """ memory_files: list[DetectedMemoryFile] = [] + seen_files: set[Path] = set() - # Check root CLAUDE.md - root_memory = self.project_root / self.MEMORY_FILE_NAME - if root_memory.exists() and root_memory.is_file(): - try: - content = root_memory.read_text(encoding="utf-8") - content_preview = content[:100] if content else "" - memory_files.append( - DetectedMemoryFile( - name="CLAUDE", - file_path=root_memory, - relative_path=self.MEMORY_FILE_NAME, - is_root=True, - content_preview=content_preview, + memory_file_names: dict[str, str] = {} + for tool_name, cap in self._get_registry_entries(): + if cap.memory_file_name: + memory_file_names[cap.memory_file_name] = tool_name + + for mem_file_name, tool_name in memory_file_names.items(): + stem = Path(mem_file_name).stem + + root_memory = self.project_root / mem_file_name + if root_memory.exists() and root_memory.is_file() and root_memory not in seen_files: + seen_files.add(root_memory) + try: + content = root_memory.read_text(encoding="utf-8") + content_preview = content[:100] if content else "" + memory_files.append( + DetectedMemoryFile( + name=stem, + file_path=root_memory, + relative_path=mem_file_name, + is_root=True, + content_preview=content_preview, + source_tool=tool_name, + ) ) - ) - except Exception as e: - logger.warning(f"Failed to read memory file {root_memory}: {e}") + except Exception as e: + logger.warning(f"Failed to read memory file {root_memory}: {e}") - # Find CLAUDE.md in subdirectories (not too deep) - for file_path in self.project_root.rglob(self.MEMORY_FILE_NAME): - if file_path == root_memory: - continue - if not file_path.is_file(): - continue + for file_path in self.project_root.rglob(mem_file_name): + if file_path == root_memory: + continue + if not file_path.is_file(): + continue + if file_path in seen_files: + continue + seen_files.add(file_path) - # Skip common non-project directories - rel_path = file_path.relative_to(self.project_root) - parts = rel_path.parts - if any(p.startswith(".") and p not in {".claude", ".github"} for p in parts[:-1]): - continue - if any(p in {"node_modules", "venv", ".venv", "__pycache__", "dist", "build"} for p in parts): - continue + rel_path = file_path.relative_to(self.project_root) + parts = rel_path.parts + if any(p.startswith(".") and p not in {".claude", ".github"} for p in parts[:-1]): + continue + if any(p in {"node_modules", "venv", ".venv", "__pycache__", "dist", "build"} for p in parts): + continue - try: - content = file_path.read_text(encoding="utf-8") - content_preview = content[:100] if content else "" - # Create name from directory path - parent_parts = parts[:-1] - name = "-".join(parent_parts) + "-CLAUDE" if parent_parts else "CLAUDE" + try: + content = file_path.read_text(encoding="utf-8") + content_preview = content[:100] if content else "" + parent_parts = parts[:-1] + name = "-".join(parent_parts) + f"-{stem}" if parent_parts else stem - memory_files.append( - DetectedMemoryFile( - name=name, - file_path=file_path, - relative_path=str(rel_path), - is_root=False, - content_preview=content_preview, + memory_files.append( + DetectedMemoryFile( + name=name, + file_path=file_path, + relative_path=str(rel_path), + is_root=False, + content_preview=content_preview, + source_tool=tool_name, + ) ) - ) - except Exception as e: - logger.warning(f"Failed to read memory file {file_path}: {e}") + except Exception as e: + logger.warning(f"Failed to read memory file {file_path}: {e}") return memory_files @@ -850,13 +963,14 @@ def to_package_components( mcp_servers = [] for mcp in detection_result.mcp_servers: + ide = [mcp.source_tool] if mcp.source_tool else ["claude"] mcp_servers.append( MCPServerComponent( name=mcp.name, file=f"mcp/{mcp.name}.json", description=f"MCP server from {mcp.source}" if include_descriptions else "", credentials=[], - ide_support=["claude"], + ide_support=ide, ) ) @@ -866,7 +980,7 @@ def to_package_components( file=hook.relative_path, description=f"{hook.hook_type} hook" if include_descriptions else "", hook_type=hook.hook_type, - ide_support=["claude"], + ide_support=[hook.source_tool] if hook.source_tool else ["claude"], ) for hook in detection_result.hooks ] @@ -877,7 +991,7 @@ def to_package_components( file=cmd.relative_path, description=f"{cmd.command_type} command" if include_descriptions else "", command_type=cmd.command_type, - ide_support=["claude"], + ide_support=[cmd.source_tool] if cmd.source_tool else ["claude"], ) for cmd in detection_result.commands ] @@ -898,8 +1012,8 @@ def to_package_components( SkillComponent( name=skill.name, file=skill.relative_path, - description=skill.description or ("Claude skill" if include_descriptions else ""), - ide_support=["claude"], + description=skill.description or ("Skill" if include_descriptions else ""), + ide_support=[skill.source_tool] if skill.source_tool else ["claude"], ) for skill in detection_result.skills ] @@ -908,8 +1022,8 @@ def to_package_components( WorkflowComponent( name=wf.name, file=wf.relative_path, - description=wf.description or ("Windsurf workflow" if include_descriptions else ""), - ide_support=["windsurf"], + description=wf.description or ("Workflow" if include_descriptions else ""), + ide_support=[wf.source_tool] if wf.source_tool else ["windsurf"], ) for wf in detection_result.workflows ] @@ -919,7 +1033,7 @@ def to_package_components( name=mem.name, file=mem.relative_path, description=("Root memory file" if mem.is_root else "Memory file") if include_descriptions else "", - ide_support=["claude"], + ide_support=[mem.source_tool] if mem.source_tool else ["claude"], ) for mem in detection_result.memory_files ] diff --git a/devsync/core/extractor.py b/devsync/core/extractor.py index ac4c3ac..9bf5c52 100644 --- a/devsync/core/extractor.py +++ b/devsync/core/extractor.py @@ -1,10 +1,15 @@ """AI-powered practice extraction engine.""" +from __future__ import annotations + import json import logging import re from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from devsync.core.component_detector import DetectionResult from devsync.core.practice import MCPDeclaration, PracticeDeclaration from devsync.llm.prompts import ( @@ -28,19 +33,36 @@ class PracticeExtractor: def __init__(self, llm_provider: Optional[LLMProvider] = None): self._llm = llm_provider - def extract(self, project_path: Path) -> ExtractionResult: + def extract( + self, + project_path: Path, + tool_filter: list[str] | None = None, + component_filter: list[str] | None = None, + scope: str = "project", + detection: "DetectionResult | None" = None, + ) -> ExtractionResult: """Extract practices from a project directory. Args: project_path: Root directory of the project to analyze. + tool_filter: Only extract from these AI tools. + component_filter: Only extract these component types. + scope: Detection scope — project, global, or all. + detection: Pre-computed detection result. If provided, skip + internal detection (tool_filter, component_filter, scope + are ignored). Returns: ExtractionResult with extracted practices and MCP servers. """ - from devsync.core.component_detector import ComponentDetector + if detection is None: + from devsync.core.component_detector import ComponentDetector, filter_detection_result + + detector = ComponentDetector(project_path, scope=scope, tool_filter=tool_filter) + detection = detector.detect_all() - detector = ComponentDetector(project_path) - detection = detector.detect_all() + if component_filter: + detection = filter_detection_result(detection, component_filter=component_filter) instruction_files = self._read_instruction_files(project_path, detection) mcp_configs = self._read_mcp_configs(detection) diff --git a/docs/cli/extract.md b/docs/cli/extract.md index af1b53b..f0aa8b6 100644 --- a/docs/cli/extract.md +++ b/docs/cli/extract.md @@ -15,8 +15,52 @@ $ devsync extract [OPTIONS] | `--output` | `-o` | Output directory for the package | `./devsync-package` | | `--name` | `-n` | Package name | -- | | `--no-ai` | -- | Use file-copy mode instead of AI extraction | `False` | -| `--project-dir` | `-p` | Project directory to extract from | `.` | -| `--upgrade` | `-u` | Path to v1 package to upgrade to v2 format | -- | +| `--project` | `-p` | Project directory to extract from | `.` | +| `--upgrade` | -- | Path to v1 package to upgrade to v2 format | -- | +| `--tool` | `-t` | Only extract from specific AI tool(s). Repeatable. | -- | +| `--component` | `-c` | Only extract specific component types (rules, mcp, hooks, commands, skills, workflows, memory, resources). Repeatable. | -- | +| `--dry-run` | -- | Show detected components without writing files or calling the LLM | `False` | +| `--include-global` | -- | Include home directory / global configs in extraction | `False` | + +## Preview with Dry Run + +Use `--dry-run` to see what would be extracted without writing any files or making LLM calls: + +```bash +$ devsync extract --dry-run +``` + +``` +Detected Components + + Component Source Count + Rules claude 6 + Rules cursor 2 + MCP claude 3 + Commands claude 13 + + Total: 24 components from 2 tools + +Dry run — no files written. +``` + +Combine with filters to check what a filtered extraction would find: + +```bash +$ devsync extract --dry-run --tool cursor --component rules +``` + +When filters match nothing, you'll see suggestions: + +``` +No components found matching your filters. + + Active: --tool cursor --component hooks + + Suggestions: + - Run devsync extract --dry-run without filters to see all available components + - Try --include-global to include home directory configs +``` ## AI-Powered Mode (Default) @@ -27,22 +71,21 @@ $ devsync extract --output ./team-standards --name team-standards ``` ``` -Extracting practices from /home/user/my-project... +Detected Components - Scanning: .claude/rules/ (3 files) - Scanning: .cursor/rules/ (2 files) - Scanning: MCP configurations (1 server) + Component Source Count + Rules claude 3 + Rules cursor 2 + MCP claude 1 - Extracted 4 practice declarations: - - type-safety: Enforce strict type annotations - - error-handling: Structured error handling patterns - - code-style: Formatting and naming conventions - - testing: Test-first development practices + Total: 6 components from 2 tools - Extracted 1 MCP server: - - github: GitHub API access +Extraction complete (AI-powered) + Practices generated: 4 + MCP servers: 1 + Source files: 5 -Package written to: ./team-standards/devsync-package.yaml + Package written to: ./team-standards/ ``` The output directory contains: @@ -99,6 +142,38 @@ $ devsync extract --project-dir ~/other-project --output ./other-standards --nam $ devsync extract --output ~/shared/team-standards --name team-standards ``` +### Extract only from specific tools + +```bash +$ devsync extract --tool cursor --tool claude +``` + +Extract only from Cursor and Claude Code, skipping other tools. + +### Extract only specific component types + +```bash +$ devsync extract --component rules --component mcp +``` + +Extract only instruction rules and MCP configurations, skipping hooks, commands, etc. + +### Include global configurations + +```bash +$ devsync extract --include-global +``` + +By default, DevSync only scans the project directory. Use `--include-global` to include global AI tool configurations (e.g. `~/.cursor/mcp.json`) in addition to project-level ones. + +### Combine filters + +```bash +$ devsync extract --tool claude --component rules --include-global +``` + +Extract only instruction rules from Claude Code, including global configurations. + ## What Gets Extracted DevSync scans for: @@ -107,7 +182,16 @@ DevSync scans for: |--------|----------| | Instructions/rules | `.claude/rules/`, `.cursor/rules/`, `.windsurf/rules/`, `.github/instructions/`, `.kiro/steering/`, `.clinerules/`, `.roo/rules/` | | MCP configurations | `.claude/settings.local.json`, `.cursor/mcp.json`, `.vscode/mcp.json` | -| Single-file configs | `AGENTS.md`, `CONVENTIONS.md`, `GEMINI.md` | +| Hooks | `.claude/hooks/` | +| Commands | `.claude/commands/`, `.roo/commands/` | +| Skills | `.claude/skills/` | +| Workflows | `.windsurf/workflows/` | +| Memory files | `CLAUDE.md`, `GEMINI.md` | +| Single-file configs | `AGENTS.md`, `CONVENTIONS.md`, `ANTEROOM.md` | +| Resources | `.devsync/resources/` | + +!!! tip + Use `devsync tools --verbose` to see which component types each tool supports and the valid `--component` filter names. !!! tip The more AI rule files your project has, the richer the extracted practices will be. DevSync works best when extracting from projects that already have well-defined coding standards. diff --git a/tests/unit/cli/test_extract.py b/tests/unit/cli/test_extract.py index 304c537..ed251bc 100644 --- a/tests/unit/cli/test_extract.py +++ b/tests/unit/cli/test_extract.py @@ -1,15 +1,45 @@ """Tests for extract CLI command.""" +import warnings from pathlib import Path from unittest.mock import MagicMock, patch import yaml -from devsync.cli.extract import _upgrade_v1_package, extract_command +from devsync.cli.extract import ( + _display_detection_summary, + _get_detection_rows, + _upgrade_v1_package, + extract_command, +) +from devsync.core.component_detector import ( + DetectedInstruction, + DetectedMCPServer, + DetectionResult, +) from devsync.core.practice import PracticeDeclaration from devsync.llm.response_models import ExtractionResult +def _make_detection( + instructions: list[DetectedInstruction] | None = None, + mcp_servers: list[DetectedMCPServer] | None = None, +) -> DetectionResult: + """Build a DetectionResult for testing.""" + return DetectionResult( + instructions=instructions or [], + mcp_servers=mcp_servers or [], + ) + + +def _make_project_with_rules(tmp_path: Path) -> Path: + """Create a project dir with a Claude rule file so detection finds something.""" + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "test-rule.md").write_text("# Test\nA test rule.") + return tmp_path + + class TestExtractCommand: def test_extract_invalid_path(self) -> None: result = extract_command(project_dir="/nonexistent/path") @@ -18,6 +48,7 @@ def test_extract_invalid_path(self) -> None: @patch("devsync.cli.extract.PracticeExtractor") @patch("devsync.cli.extract.load_config") def test_extract_no_ai(self, mock_config: MagicMock, mock_extractor_cls: MagicMock, tmp_path: Path) -> None: + project_dir = _make_project_with_rules(tmp_path) output_dir = tmp_path / "output" mock_extractor = MagicMock() mock_extractor.extract.return_value = ExtractionResult( @@ -31,7 +62,7 @@ def test_extract_no_ai(self, mock_config: MagicMock, mock_extractor_cls: MagicMo output=str(output_dir), name="test-pkg", no_ai=True, - project_dir=str(tmp_path), + project_dir=str(project_dir), ) assert result == 0 @@ -48,18 +79,253 @@ def test_extract_no_ai(self, mock_config: MagicMock, mock_extractor_cls: MagicMo def test_extract_no_api_key_fallback( self, mock_config: MagicMock, mock_extractor_cls: MagicMock, mock_resolve: MagicMock, tmp_path: Path ) -> None: + project_dir = _make_project_with_rules(tmp_path) output_dir = tmp_path / "output" mock_config.return_value = MagicMock(provider=None, model=None) mock_extractor = MagicMock() mock_extractor.extract.return_value = ExtractionResult(ai_powered=False) mock_extractor_cls.return_value = mock_extractor - result = extract_command(output=str(output_dir), project_dir=str(tmp_path)) + result = extract_command(output=str(output_dir), project_dir=str(project_dir)) assert result == 0 mock_extractor_cls.assert_called_once_with(llm_provider=None) +class TestExtractFilterValidation: + """Test validation of --tool, --component, --scope options.""" + + def test_invalid_tool_rejected(self, tmp_path: Path) -> None: + result = extract_command(project_dir=str(tmp_path), no_ai=True, tool=["nonexistent-tool"]) + assert result == 1 + + def test_invalid_component_rejected(self, tmp_path: Path) -> None: + result = extract_command(project_dir=str(tmp_path), no_ai=True, component=["nonexistent-component"]) + assert result == 1 + + def test_invalid_scope_rejected(self, tmp_path: Path) -> None: + result = extract_command(project_dir=str(tmp_path), no_ai=True, scope="invalid") + assert result == 1 + + def test_valid_tool_accepted(self, tmp_path: Path) -> None: + result = extract_command( + project_dir=str(tmp_path), + no_ai=True, + tool=["claude"], + output=str(tmp_path / "out"), + ) + # Returns 0 even with zero results (zero-result is not an error) + assert result == 0 + + def test_valid_component_accepted(self, tmp_path: Path) -> None: + result = extract_command( + project_dir=str(tmp_path), + no_ai=True, + component=["mcp"], + output=str(tmp_path / "out"), + ) + assert result == 0 + + @patch("devsync.cli.extract.PracticeExtractor") + @patch("devsync.cli.extract.load_config") + def test_filters_pass_to_extractor( + self, mock_config: MagicMock, mock_extractor_cls: MagicMock, tmp_path: Path + ) -> None: + """Detection is done in extract_command; extractor receives pre-computed DetectionResult.""" + project_dir = _make_project_with_rules(tmp_path) + output_dir = tmp_path / "output" + mock_extractor = MagicMock() + mock_extractor.extract.return_value = ExtractionResult(ai_powered=False) + mock_extractor_cls.return_value = mock_extractor + + extract_command( + output=str(output_dir), + no_ai=True, + project_dir=str(project_dir), + tool=["claude"], + ) + + mock_extractor.extract.assert_called_once() + call_kwargs = mock_extractor.extract.call_args + # Extractor now receives a pre-computed DetectionResult via detection kwarg + assert "detection" in call_kwargs.kwargs + assert call_kwargs.kwargs["detection"] is not None + + +class TestExtractDryRun: + """Test --dry-run flag behaviour.""" + + def test_dry_run_shows_detection_no_files_written(self, tmp_path: Path) -> None: + """Dry run should return 0 and not create output directory.""" + project_dir = _make_project_with_rules(tmp_path) + output_dir = tmp_path / "output" + + result = extract_command( + output=str(output_dir), + no_ai=True, + project_dir=str(project_dir), + dry_run=True, + ) + + assert result == 0 + assert not output_dir.exists() + + def test_dry_run_with_tool_filter(self, tmp_path: Path) -> None: + """Dry run with a tool filter that matches something.""" + project_dir = _make_project_with_rules(tmp_path) + + result = extract_command( + no_ai=True, + project_dir=str(project_dir), + tool=["claude"], + dry_run=True, + ) + + assert result == 0 + + def test_dry_run_zero_results(self, tmp_path: Path) -> None: + """Dry run with filters that match nothing returns 0.""" + result = extract_command( + no_ai=True, + project_dir=str(tmp_path), + tool=["claude"], + component=["hooks"], + dry_run=True, + ) + + assert result == 0 + + +class TestExtractIncludeGlobal: + """Test --include-global flag and deprecated --scope behaviour.""" + + def test_include_global_maps_to_scope_all(self, tmp_path: Path) -> None: + """--include-global should use scope 'all' internally.""" + project_dir = _make_project_with_rules(tmp_path) + + with patch("devsync.cli.extract.ComponentDetector") as mock_cd_cls: + mock_cd = MagicMock() + mock_cd.detect_all.return_value = DetectionResult( + instructions=[ + DetectedInstruction( + name="test", + file_path=project_dir / ".claude" / "rules" / "test-rule.md", + relative_path=".claude/rules/test-rule.md", + source_ide="claude", + ) + ] + ) + mock_cd_cls.return_value = mock_cd + + with patch("devsync.cli.extract.PracticeExtractor") as mock_ext_cls: + mock_ext = MagicMock() + mock_ext.extract.return_value = ExtractionResult(ai_powered=False) + mock_ext_cls.return_value = mock_ext + + extract_command( + output=str(tmp_path / "out"), + no_ai=True, + project_dir=str(project_dir), + include_global=True, + ) + + mock_cd_cls.assert_called_once() + call_kwargs = mock_cd_cls.call_args + assert call_kwargs.kwargs.get("scope") == "all" + + def test_deprecated_scope_still_works(self, tmp_path: Path) -> None: + """--scope all should still work but trigger deprecation warning.""" + project_dir = _make_project_with_rules(tmp_path) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = extract_command( + output=str(tmp_path / "out"), + no_ai=True, + project_dir=str(project_dir), + scope="all", + ) + + assert result == 0 + deprecation_warnings = [x for x in w if issubclass(x.category, DeprecationWarning)] + assert len(deprecation_warnings) >= 1 + + +class TestExtractZeroResult: + """Test zero-result warning behaviour.""" + + def test_zero_result_returns_zero(self, tmp_path: Path) -> None: + """Empty project with filters should return 0, not error.""" + result = extract_command( + no_ai=True, + project_dir=str(tmp_path), + tool=["cursor"], + component=["hooks"], + ) + + assert result == 0 + + def test_zero_result_no_output_dir_created(self, tmp_path: Path) -> None: + """No output directory when zero components found.""" + output_dir = tmp_path / "output" + + extract_command( + output=str(output_dir), + no_ai=True, + project_dir=str(tmp_path), + tool=["cursor"], + component=["hooks"], + ) + + assert not output_dir.exists() + + +class TestDetectionSummaryHelpers: + """Test the detection summary display helpers.""" + + def test_get_detection_rows_empty(self) -> None: + rows = _get_detection_rows(DetectionResult()) + assert rows == [] + + def test_get_detection_rows_with_instructions(self) -> None: + detection = _make_detection( + instructions=[ + DetectedInstruction( + name="rule1", + file_path=Path("/tmp/r1.md"), + relative_path=".claude/rules/r1.md", + source_ide="claude", + ), + DetectedInstruction( + name="rule2", + file_path=Path("/tmp/r2.md"), + relative_path=".cursor/rules/r2.mdc", + source_ide="cursor", + ), + ] + ) + rows = _get_detection_rows(detection) + assert len(rows) == 2 + labels = {r[0] for r in rows} + assert "Rules" in labels + + def test_get_detection_rows_groups_by_source(self) -> None: + detection = _make_detection( + instructions=[ + DetectedInstruction(name="r1", file_path=Path("/t/r1.md"), relative_path="r1.md", source_ide="claude"), + DetectedInstruction(name="r2", file_path=Path("/t/r2.md"), relative_path="r2.md", source_ide="claude"), + ] + ) + rows = _get_detection_rows(detection) + # Both claude instructions grouped into one row + assert len(rows) == 1 + assert rows[0] == ("Rules", "claude", 2) + + def test_display_detection_summary_no_crash_on_empty(self) -> None: + """Should not crash when detection is empty.""" + _display_detection_summary(DetectionResult()) + + class TestUpgradeV1Package: def test_upgrade_nonexistent_path(self) -> None: result = _upgrade_v1_package("/nonexistent/path") diff --git a/tests/unit/cli/test_tools.py b/tests/unit/cli/test_tools.py new file mode 100644 index 0000000..30c41af --- /dev/null +++ b/tests/unit/cli/test_tools.py @@ -0,0 +1,37 @@ +"""Tests for tools CLI command.""" + +from unittest.mock import MagicMock, patch + +from devsync.cli.tools import show_tools + + +class TestShowTools: + def test_show_tools_returns_zero(self) -> None: + result = show_tools() + assert result == 0 + + def test_show_tools_verbose_returns_zero(self) -> None: + result = show_tools(verbose=True) + assert result == 0 + + def test_show_tools_verbose_shows_capabilities(self, capsys: object) -> None: + """Verbose mode should include capability info without crashing.""" + result = show_tools(verbose=True) + assert result == 0 + + @patch("devsync.cli.tools.get_detector") + def test_show_tools_verbose_with_mock(self, mock_get_detector: MagicMock) -> None: + """Verify verbose mode accesses capability registry.""" + mock_detector = MagicMock() + mock_tool = MagicMock() + mock_tool.tool_name = "TestTool" + mock_tool.is_installed.return_value = True + + from devsync.core.models import AIToolType + + mock_detector.tools = {AIToolType.CLAUDE: mock_tool} + mock_detector.detect_installed_tools.return_value = [mock_tool] + mock_get_detector.return_value = mock_detector + + result = show_tools(verbose=True) + assert result == 0 diff --git a/tests/unit/packages/test_component_detector.py b/tests/unit/packages/test_component_detector.py index 7483df8..26f084b 100644 --- a/tests/unit/packages/test_component_detector.py +++ b/tests/unit/packages/test_component_detector.py @@ -9,7 +9,10 @@ ComponentDetector, DetectedHook, DetectedInstruction, + DetectedMCPServer, + DetectedResource, DetectionResult, + filter_detection_result, ) @@ -550,3 +553,335 @@ def test_detect_recursive_copilot_instructions(self, temp_project: Path) -> None inst = result.instructions[0] assert "backend" in inst.name or "api" in inst.name assert inst.source_ide == "copilot" + + +class TestMultiToolMCPDetection: + """Test MCP server detection from multiple AI tools.""" + + def test_detect_cursor_mcp(self, temp_project: Path) -> None: + """Test detection of MCP servers from Cursor config.""" + cursor_dir = temp_project / ".cursor" + cursor_dir.mkdir(parents=True) + + config = {"mcpServers": {"my-server": {"command": "node", "args": ["server.js"]}}} + (cursor_dir / "mcp.json").write_text(json.dumps(config)) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + cursor_servers = [s for s in result.mcp_servers if s.source_tool == "cursor"] + assert len(cursor_servers) == 1 + assert cursor_servers[0].name == "my-server" + + def test_detect_roo_mcp(self, temp_project: Path) -> None: + """Test detection of MCP servers from Roo Code config.""" + roo_dir = temp_project / ".roo" + roo_dir.mkdir(parents=True) + + config = {"mcpServers": {"roo-server": {"command": "python", "args": ["-m", "server"]}}} + (roo_dir / "mcp.json").write_text(json.dumps(config)) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + roo_servers = [s for s in result.mcp_servers if s.source_tool == "roo"] + assert len(roo_servers) == 1 + assert roo_servers[0].name == "roo-server" + + def test_detect_copilot_mcp_uses_servers_key(self, temp_project: Path) -> None: + """Test that Copilot MCP uses 'servers' key, not 'mcpServers'.""" + vscode_dir = temp_project / ".vscode" + vscode_dir.mkdir(parents=True) + + config = {"servers": {"copilot-server": {"command": "npx", "args": ["server"]}}} + (vscode_dir / "mcp.json").write_text(json.dumps(config)) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + copilot_servers = [s for s in result.mcp_servers if s.source_tool == "copilot"] + assert len(copilot_servers) == 1 + assert copilot_servers[0].name == "copilot-server" + + def test_detect_claude_mcp(self, temp_project: Path) -> None: + """Test detection of MCP servers from Claude config.""" + claude_dir = temp_project / ".claude" + claude_dir.mkdir(parents=True) + + config = {"mcpServers": {"claude-server": {"command": "uvx", "args": ["mcp-server"]}}} + (claude_dir / "settings.local.json").write_text(json.dumps(config)) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + claude_servers = [s for s in result.mcp_servers if s.source_tool == "claude"] + assert len(claude_servers) == 1 + assert claude_servers[0].name == "claude-server" + + def test_detect_multi_tool_mcp(self, temp_project: Path) -> None: + """Test detection of MCP servers from multiple tools simultaneously.""" + # Claude + claude_dir = temp_project / ".claude" + claude_dir.mkdir(parents=True) + (claude_dir / "settings.local.json").write_text( + json.dumps({"mcpServers": {"shared-server": {"command": "cmd1"}}}) + ) + + # Cursor + cursor_dir = temp_project / ".cursor" + cursor_dir.mkdir(parents=True) + (cursor_dir / "mcp.json").write_text(json.dumps({"mcpServers": {"cursor-only": {"command": "cmd2"}}})) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + assert len(result.mcp_servers) >= 2 + tools = {s.source_tool for s in result.mcp_servers} + assert "claude" in tools + assert "cursor" in tools + + def test_source_tool_set_on_mcp_from_devsync_dir(self, temp_project: Path) -> None: + """Test that MCP servers from .devsync/mcp/ get source_tool='devsync'.""" + mcp_dir = temp_project / ".devsync" / "mcp" + mcp_dir.mkdir(parents=True) + (mcp_dir / "fallback.json").write_text(json.dumps({"command": "python"})) + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].source_tool == "devsync" + + +class TestToolFilter: + """Test tool filtering on ComponentDetector.""" + + def test_single_tool_filter(self, temp_project: Path) -> None: + """Test filtering to a single tool.""" + # Create claude + cursor instructions + (temp_project / ".claude" / "rules").mkdir(parents=True) + (temp_project / ".claude" / "rules" / "rule.md").write_text("# Rule") + (temp_project / ".cursor" / "rules").mkdir(parents=True) + (temp_project / ".cursor" / "rules" / "rule.mdc").write_text("# Rule") + + detector = ComponentDetector(temp_project, tool_filter=["claude"]) + result = detector.detect_all() + + assert len(result.instructions) == 1 + assert result.instructions[0].source_ide == "claude" + + def test_multiple_tool_filter(self, temp_project: Path) -> None: + """Test filtering to multiple tools.""" + (temp_project / ".claude" / "rules").mkdir(parents=True) + (temp_project / ".claude" / "rules" / "rule.md").write_text("# Rule") + (temp_project / ".cursor" / "rules").mkdir(parents=True) + (temp_project / ".cursor" / "rules" / "rule.mdc").write_text("# Rule") + (temp_project / ".windsurf" / "rules").mkdir(parents=True) + (temp_project / ".windsurf" / "rules" / "rule.md").write_text("# Rule") + + detector = ComponentDetector(temp_project, tool_filter=["claude", "cursor"]) + result = detector.detect_all() + + assert len(result.instructions) == 2 + ides = {i.source_ide for i in result.instructions} + assert ides == {"claude", "cursor"} + + def test_no_filter_returns_all(self, temp_project: Path) -> None: + """Test that no filter returns everything.""" + (temp_project / ".claude" / "rules").mkdir(parents=True) + (temp_project / ".claude" / "rules" / "rule.md").write_text("# Rule") + (temp_project / ".cursor" / "rules").mkdir(parents=True) + (temp_project / ".cursor" / "rules" / "rule.mdc").write_text("# Rule") + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + assert len(result.instructions) == 2 + + +class TestComponentFilter: + """Test component type filtering via filter_detection_result.""" + + def test_filter_instructions_only(self) -> None: + """Test filtering to instructions only.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") + ], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], + ) + + filtered = filter_detection_result(result, component_filter=["rules"]) + assert len(filtered.instructions) == 1 + assert len(filtered.mcp_servers) == 0 + + def test_filter_mcp_only(self) -> None: + """Test filtering to MCP servers only.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") + ], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], + ) + + filtered = filter_detection_result(result, component_filter=["mcp"]) + assert len(filtered.instructions) == 0 + assert len(filtered.mcp_servers) == 1 + + def test_filter_combined(self) -> None: + """Test filtering to multiple component types.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") + ], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], + hooks=[DetectedHook(name="h", file_path=Path("/h.sh"), relative_path="h.sh", hook_type="PreToolUse")], + ) + + filtered = filter_detection_result(result, component_filter=["rules", "mcp"]) + assert len(filtered.instructions) == 1 + assert len(filtered.mcp_servers) == 1 + assert len(filtered.hooks) == 0 + + def test_filter_by_tool_and_component(self) -> None: + """Test filtering by both tool and component.""" + result = DetectionResult( + mcp_servers=[ + DetectedMCPServer(name="s1", file_path=Path("/fake"), config={}, source="x", source_tool="claude"), + DetectedMCPServer(name="s2", file_path=Path("/fake"), config={}, source="y", source_tool="cursor"), + ], + ) + + filtered = filter_detection_result(result, tool_filter=["claude"], component_filter=["mcp"]) + assert len(filtered.mcp_servers) == 1 + assert filtered.mcp_servers[0].source_tool == "claude" + + def test_no_filter_returns_all(self) -> None: + """Test that no filter returns everything.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") + ], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], + ) + + filtered = filter_detection_result(result) + assert len(filtered.instructions) == 1 + assert len(filtered.mcp_servers) == 1 + + def test_tool_filter_on_instructions(self) -> None: + """Test that tool_filter filters instructions by source_ide.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r1", file_path=Path("/r1.md"), relative_path="r1.md", source_ide="claude"), + DetectedInstruction(name="r2", file_path=Path("/r2.md"), relative_path="r2.md", source_ide="cursor"), + DetectedInstruction(name="r3", file_path=Path("/r3.md"), relative_path="r3.md", source_ide="windsurf"), + ], + ) + + filtered = filter_detection_result(result, tool_filter=["cursor"]) + assert len(filtered.instructions) == 1 + assert filtered.instructions[0].source_ide == "cursor" + + def test_tool_filter_preserves_resources(self) -> None: + """Test that tool_filter does not drop resources (they are tool-agnostic).""" + result = DetectionResult( + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude"), + ], + resources=[ + DetectedResource(name="res", file_path=Path("/r.txt"), relative_path="r.txt", size=10, checksum="abc"), + ], + ) + + filtered = filter_detection_result(result, tool_filter=["cursor"]) + # MCP filtered out (source_tool=claude, filter=cursor) + assert len(filtered.mcp_servers) == 0 + # Resources preserved despite tool_filter (they're tool-agnostic) + assert len(filtered.resources) == 1 + + def test_tool_filter_multiple_on_instructions(self) -> None: + """Test that tool_filter with multiple tools filters instructions correctly.""" + result = DetectionResult( + instructions=[ + DetectedInstruction(name="r1", file_path=Path("/r1.md"), relative_path="r1.md", source_ide="claude"), + DetectedInstruction(name="r2", file_path=Path("/r2.md"), relative_path="r2.md", source_ide="cursor"), + DetectedInstruction(name="r3", file_path=Path("/r3.md"), relative_path="r3.md", source_ide="windsurf"), + ], + ) + + filtered = filter_detection_result(result, tool_filter=["claude", "windsurf"]) + assert len(filtered.instructions) == 2 + ides = {i.source_ide for i in filtered.instructions} + assert ides == {"claude", "windsurf"} + + +class TestScopeFilter: + """Test scope-based detection.""" + + def test_project_scope_default(self, temp_project: Path) -> None: + """Test that project scope is the default.""" + detector = ComponentDetector(temp_project) + assert detector.scope == "project" + + def test_project_scope_detects_project_configs(self, temp_project: Path) -> None: + """Test that project scope detects project-level MCP configs.""" + claude_dir = temp_project / ".claude" + claude_dir.mkdir(parents=True) + config = {"mcpServers": {"server": {"command": "cmd"}}} + (claude_dir / "settings.local.json").write_text(json.dumps(config)) + + detector = ComponentDetector(temp_project, scope="project") + result = detector.detect_all() + + assert len(result.mcp_servers) >= 1 + + def test_source_tool_on_hooks(self, temp_project: Path) -> None: + """Test that source_tool is set on detected hooks.""" + hooks_dir = temp_project / ".claude" / "hooks" + hooks_dir.mkdir(parents=True) + (hooks_dir / "preToolUse.sh").write_text("#!/bin/bash") + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + assert len(result.hooks) == 1 + assert result.hooks[0].source_tool == "claude" + + def test_source_tool_on_commands(self, temp_project: Path) -> None: + """Test that source_tool is set on detected commands.""" + cmd_dir = temp_project / ".claude" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "build.sh").write_text("#!/bin/bash") + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + assert len(result.commands) == 1 + assert result.commands[0].source_tool == "claude" + + def test_invalid_scope_rejected(self, temp_project: Path) -> None: + """Test that invalid scope raises ValueError.""" + with pytest.raises(ValueError, match="Invalid scope"): + ComponentDetector(temp_project, scope="invalid") + + def test_source_tool_on_roo_commands(self, temp_project: Path) -> None: + """Test that source_tool is set correctly on Roo commands.""" + cmd_dir = temp_project / ".roo" / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "deploy.sh").write_text("#!/bin/bash") + + detector = ComponentDetector(temp_project) + result = detector.detect_all() + + roo_cmds = [c for c in result.commands if c.source_tool == "roo"] + assert len(roo_cmds) == 1 + assert roo_cmds[0].name == "deploy"