From 475d2b2940e45025fc933abf576c941ede697666 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:10:02 -0500 Subject: [PATCH 1/7] feat(core): add multi-tool MCP detection and extract filtering (#86) Replace hardcoded Claude-only MCP/hooks/commands/skills/workflows/memory detection with registry-driven paths from CAPABILITY_REGISTRY. Add --tool, --component, --scope CLI options to devsync extract. Add mcp_servers_json_key to IDECapability for per-tool JSON key handling (e.g. Copilot uses "servers"). Add source_tool tracking on all Detected* dataclasses and use it in to_package_components for dynamic ide_support. --- devsync/ai_tools/capability_registry.py | 2 + devsync/cli/extract.py | 40 +- devsync/cli/main.py | 30 ++ devsync/core/component_detector.py | 429 +++++++++++------- devsync/core/extractor.py | 18 +- tests/unit/cli/test_extract.py | 65 +++ .../unit/packages/test_component_detector.py | 275 +++++++++++ 7 files changed, 692 insertions(+), 167 deletions(-) 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..d4a91f6 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -21,6 +21,9 @@ 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", ) -> int: """Extract practices from the current project into a shareable package. @@ -30,6 +33,9 @@ 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. Returns: Exit code (0 = success). @@ -37,6 +43,33 @@ def extract_command( if upgrade: return _upgrade_v1_package(upgrade, output=output, name=name, no_ai=no_ai) + # Validate scope + if scope not in ("project", "global", "all"): + console.print(f"[red]Invalid scope: {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: + from devsync.core.component_detector import COMPONENT_TYPE_MAP + + 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]") @@ -64,7 +97,12 @@ def extract_command( console=console, ) as progress: task = progress.add_task("Scanning project...", total=None) - result = extractor.extract(project_path) + result = extractor.extract( + project_path, + tool_filter=tool, + component_filter=component, + scope=scope, + ) progress.update(task, description="Building package...") output_path.mkdir(parents=True, exist_ok=True) diff --git a/devsync/cli/main.py b/devsync/cli/main.py index ceed233..605a67b 100644 --- a/devsync/cli/main.py +++ b/devsync/cli/main.py @@ -70,6 +70,24 @@ 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="Only extract specific component types (rules, mcp, hooks, commands, skills, workflows). Repeatable.", + ), + scope: str = typer.Option( + "project", + "--scope", + "-s", + help="Detection scope: project, global, or all", + ), ) -> None: """Extract practices from a project into a shareable package. @@ -86,6 +104,15 @@ 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 + + # Extract from global configs too + devsync extract --scope all + # Upgrade v1 package to v2 devsync extract --upgrade ./old-package """ @@ -97,6 +124,9 @@ def extract( no_ai=no_ai, project_dir=project_dir, upgrade=upgrade, + tool=tool, + component=component, + scope=scope, ) raise typer.Exit(code=exit_code) diff --git a/devsync/core/component_detector.py b/devsync/core/component_detector.py index b92103e..20ab0a9 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,82 @@ 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=_get_field("resources", result.resources), + 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 +310,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 +318,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 +325,34 @@ class ComponentDetector: MAX_RESOURCE_SIZE = 200 * 1024 * 1024 # 200 MB WARN_RESOURCE_SIZE = 50 * 1024 * 1024 # 50 MB - def __init__(self, project_root: Path): + 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 """ 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 +361,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 +384,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 +401,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 +430,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 +459,65 @@ 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() + 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 +537,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 +564,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 +592,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 +619,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 +647,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 +714,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 +723,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 +737,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 +756,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 +788,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 +820,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 +851,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 +952,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 +969,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 +980,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 +1001,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 +1011,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 +1022,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..fcffa29 100644 --- a/devsync/core/extractor.py +++ b/devsync/core/extractor.py @@ -28,20 +28,32 @@ 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", + ) -> 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. Returns: ExtractionResult with extracted practices and MCP servers. """ - from devsync.core.component_detector import ComponentDetector + from devsync.core.component_detector import ComponentDetector, filter_detection_result - detector = ComponentDetector(project_path) + detector = ComponentDetector(project_path, scope=scope, tool_filter=tool_filter) 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/tests/unit/cli/test_extract.py b/tests/unit/cli/test_extract.py index 304c537..9379973 100644 --- a/tests/unit/cli/test_extract.py +++ b/tests/unit/cli/test_extract.py @@ -60,6 +60,71 @@ def test_extract_no_api_key_fallback( 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: + """Test that an invalid tool name returns error.""" + 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: + """Test that an invalid component name returns error.""" + 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: + """Test that an invalid scope returns error.""" + 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: + """Test that valid tool names are accepted.""" + result = extract_command( + project_dir=str(tmp_path), + no_ai=True, + tool=["claude"], + output=str(tmp_path / "out"), + ) + assert result == 0 + + def test_valid_component_accepted(self, tmp_path: Path) -> None: + """Test that valid component names are accepted.""" + 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: + """Test that tool/component/scope filters are passed to extractor.""" + 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(tmp_path), + tool=["cursor"], + component=["mcp"], + scope="all", + ) + + mock_extractor.extract.assert_called_once() + call_kwargs = mock_extractor.extract.call_args + assert call_kwargs.kwargs["tool_filter"] == ["cursor"] + assert call_kwargs.kwargs["component_filter"] == ["mcp"] + assert call_kwargs.kwargs["scope"] == "all" + + class TestUpgradeV1Package: def test_upgrade_nonexistent_path(self) -> None: result = _upgrade_v1_package("/nonexistent/path") diff --git a/tests/unit/packages/test_component_detector.py b/tests/unit/packages/test_component_detector.py index 7483df8..b8609c9 100644 --- a/tests/unit/packages/test_component_detector.py +++ b/tests/unit/packages/test_component_detector.py @@ -9,7 +9,9 @@ ComponentDetector, DetectedHook, DetectedInstruction, + DetectedMCPServer, DetectionResult, + filter_detection_result, ) @@ -550,3 +552,276 @@ 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=None, 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=None, 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=None, 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=None, config={}, source="x", source_tool="claude"), + DetectedMCPServer(name="s2", file_path=None, 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=None, config={}, source="x", source_tool="claude")], + ) + + filtered = filter_detection_result(result) + assert len(filtered.instructions) == 1 + assert len(filtered.mcp_servers) == 1 + + +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_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" From b946131a2767b611ffcaca0dbdec09fcda1efe69 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:15:42 -0500 Subject: [PATCH 2/7] docs: update documentation for extract filtering (#86) --- CLAUDE.md | 8 +++++++- docs/cli/extract.md | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) 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/docs/cli/extract.md b/docs/cli/extract.md index af1b53b..a820ec1 100644 --- a/docs/cli/extract.md +++ b/docs/cli/extract.md @@ -15,8 +15,11 @@ $ 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. | -- | +| `--scope` | `-s` | Detection scope: `project`, `global`, or `all` | `project` | ## AI-Powered Mode (Default) @@ -99,6 +102,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 --scope all +``` + +By default, DevSync only scans the project directory. Use `--scope all` to include global AI tool configurations in addition to project-level ones. + +### Combine filters + +```bash +$ devsync extract --tool claude --component rules --scope all +``` + +Extract only instruction rules from Claude Code, including global configurations. + ## What Gets Extracted DevSync scans for: From 02da40141d8e76fbb8dd9f2bae1d1bdb5f681efc Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:15:45 -0500 Subject: [PATCH 3/7] fix(core): add scope validation and global path boundary check (#86) --- devsync/cli/main.py | 10 +++++++--- devsync/core/component_detector.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/devsync/cli/main.py b/devsync/cli/main.py index 605a67b..df6f24a 100644 --- a/devsync/cli/main.py +++ b/devsync/cli/main.py @@ -80,7 +80,7 @@ def extract( None, "--component", "-c", - help="Only extract specific component types (rules, mcp, hooks, commands, skills, workflows). Repeatable.", + help="Component types to extract: rules, mcp, hooks, commands, skills, workflows, memory. Repeatable.", ), scope: str = typer.Option( "project", @@ -91,8 +91,9 @@ def extract( ) -> 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 @@ -113,6 +114,9 @@ def extract( # Extract from global configs too devsync extract --scope all + # 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 """ diff --git a/devsync/core/component_detector.py b/devsync/core/component_detector.py index 20ab0a9..dd436af 100644 --- a/devsync/core/component_detector.py +++ b/devsync/core/component_detector.py @@ -325,6 +325,8 @@ class ComponentDetector: MAX_RESOURCE_SIZE = 200 * 1024 * 1024 # 200 MB WARN_RESOURCE_SIZE = 50 * 1024 * 1024 # 50 MB + 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. @@ -332,7 +334,12 @@ def __init__(self, project_root: Path, scope: str = "project", tool_filter: list 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 @@ -477,7 +484,10 @@ def _detect_mcp_servers(self) -> list[DetectedMCPServer]: paths_to_check.append((self.project_root / cap.mcp_project_config_path, cap.mcp_project_config_path)) if self.scope in ("global", "all") and cap.mcp_config_path: - expanded = Path(cap.mcp_config_path).expanduser() + 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 From b3e51e48836827fe8225f12c5a069a35794c7add Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:15:48 -0500 Subject: [PATCH 4/7] test(core): add filter and scope validation tests (#86) --- .../unit/packages/test_component_detector.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/unit/packages/test_component_detector.py b/tests/unit/packages/test_component_detector.py index b8609c9..e5cbc75 100644 --- a/tests/unit/packages/test_component_detector.py +++ b/tests/unit/packages/test_component_detector.py @@ -768,6 +768,35 @@ def test_no_filter_returns_all(self) -> None: 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_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.""" @@ -813,6 +842,11 @@ def test_source_tool_on_commands(self, temp_project: Path) -> None: 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" From 77536afcf6dd6a3c03e313dcd9c56234c1e96448 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:15:55 -0500 Subject: [PATCH 5/7] docs: update VISION.md to reflect multi-tool extraction (#86) --- VISION.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From a923b42b545d0665ff1d06968dc1c643f29cc200 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:18:55 -0500 Subject: [PATCH 6/7] fix(core): preserve resources when tool_filter is active (#86) --- devsync/core/component_detector.py | 3 +- .../unit/packages/test_component_detector.py | 38 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/devsync/core/component_detector.py b/devsync/core/component_detector.py index dd436af..97e36f0 100644 --- a/devsync/core/component_detector.py +++ b/devsync/core/component_detector.py @@ -280,7 +280,8 @@ def _get_field(field_name: str, items: list, tool_attr: str = "source_tool") -> skills=_get_field("skills", result.skills), workflows=_get_field("workflows", result.workflows), memory_files=_get_field("memory_files", result.memory_files), - resources=_get_field("resources", result.resources), + # 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, ) diff --git a/tests/unit/packages/test_component_detector.py b/tests/unit/packages/test_component_detector.py index e5cbc75..26f084b 100644 --- a/tests/unit/packages/test_component_detector.py +++ b/tests/unit/packages/test_component_detector.py @@ -10,6 +10,7 @@ DetectedHook, DetectedInstruction, DetectedMCPServer, + DetectedResource, DetectionResult, filter_detection_result, ) @@ -707,7 +708,9 @@ def test_filter_instructions_only(self) -> None: instructions=[ DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") ], - mcp_servers=[DetectedMCPServer(name="s", file_path=None, config={}, source="x", source_tool="claude")], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], ) filtered = filter_detection_result(result, component_filter=["rules"]) @@ -720,7 +723,9 @@ def test_filter_mcp_only(self) -> None: instructions=[ DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") ], - mcp_servers=[DetectedMCPServer(name="s", file_path=None, config={}, source="x", source_tool="claude")], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], ) filtered = filter_detection_result(result, component_filter=["mcp"]) @@ -733,7 +738,9 @@ def test_filter_combined(self) -> None: instructions=[ DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") ], - mcp_servers=[DetectedMCPServer(name="s", file_path=None, config={}, source="x", source_tool="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")], ) @@ -746,8 +753,8 @@ 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=None, config={}, source="x", source_tool="claude"), - DetectedMCPServer(name="s2", file_path=None, config={}, source="y", source_tool="cursor"), + 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"), ], ) @@ -761,7 +768,9 @@ def test_no_filter_returns_all(self) -> None: instructions=[ DetectedInstruction(name="r", file_path=Path("/r.md"), relative_path="r.md", source_ide="claude") ], - mcp_servers=[DetectedMCPServer(name="s", file_path=None, config={}, source="x", source_tool="claude")], + mcp_servers=[ + DetectedMCPServer(name="s", file_path=Path("/fake"), config={}, source="x", source_tool="claude") + ], ) filtered = filter_detection_result(result) @@ -782,6 +791,23 @@ def test_tool_filter_on_instructions(self) -> None: 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( From a23bdab6e3926a23478bce50a34bec92d3819a7a Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sun, 22 Feb 2026 08:51:22 -0500 Subject: [PATCH 7/7] feat(cli): add dry-run, include-global, detection summary to extract (#88) - Add --dry-run flag to preview detected components without writing files - Add --include-global flag replacing deprecated --scope option - Display Rich table summarizing detected components before extraction - Show zero-result warning with suggestions when filters match nothing - Enhance extraction output with per-tool breakdown - Add --verbose flag to devsync tools showing capabilities per tool - Update docs/cli/extract.md with new flags and examples --- devsync/cli/extract.py | 156 +++++++++++++++++++--- devsync/cli/main.py | 33 ++++- devsync/cli/tools.py | 43 ++++-- devsync/core/extractor.py | 22 +++- docs/cli/extract.md | 83 +++++++++--- tests/unit/cli/test_extract.py | 233 ++++++++++++++++++++++++++++++--- tests/unit/cli/test_tools.py | 37 ++++++ 7 files changed, 539 insertions(+), 68 deletions(-) create mode 100644 tests/unit/cli/test_tools.py diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index d4a91f6..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, @@ -24,6 +116,8 @@ def extract_command( 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. @@ -35,7 +129,9 @@ def extract_command( 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. + 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). @@ -43,9 +139,22 @@ 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 scope not in ("project", "global", "all"): - console.print(f"[red]Invalid scope: {scope}. Must be project, global, or all.[/red]") + 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 @@ -61,8 +170,6 @@ def extract_command( # Validate component names if component: - from devsync.core.component_detector import COMPONENT_TYPE_MAP - for c in component: if c.lower() not in COMPONENT_TYPE_MAP: valid = sorted(set(COMPONENT_TYPE_MAP.keys())) @@ -75,6 +182,27 @@ def extract_command( 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" @@ -96,13 +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, - tool_filter=tool, - component_filter=component, - scope=scope, - ) + 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) @@ -134,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 df6f24a..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) @@ -86,7 +93,18 @@ def extract( "project", "--scope", "-s", - help="Detection scope: project, global, or all", + 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. @@ -111,8 +129,11 @@ def extract( # Extract only MCP configs devsync extract --component mcp - # Extract from global configs too - devsync extract --scope all + # 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 @@ -131,6 +152,8 @@ def extract( 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/extractor.py b/devsync/core/extractor.py index fcffa29..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 ( @@ -34,6 +39,7 @@ def extract( 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. @@ -42,17 +48,21 @@ def extract( 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, filter_detection_result + 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, scope=scope, tool_filter=tool_filter) + detection = detector.detect_all() - if component_filter: - detection = filter_detection_result(detection, component_filter=component_filter) + 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 a820ec1..f0aa8b6 100644 --- a/docs/cli/extract.md +++ b/docs/cli/extract.md @@ -19,7 +19,48 @@ $ devsync extract [OPTIONS] | `--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. | -- | -| `--scope` | `-s` | Detection scope: `project`, `global`, or `all` | `project` | +| `--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) @@ -30,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: @@ -121,15 +161,15 @@ Extract only instruction rules and MCP configurations, skipping hooks, commands, ### Include global configurations ```bash -$ devsync extract --scope all +$ devsync extract --include-global ``` -By default, DevSync only scans the project directory. Use `--scope all` to include global AI tool configurations in addition to project-level ones. +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 --scope all +$ devsync extract --tool claude --component rules --include-global ``` Extract only instruction rules from Claude Code, including global configurations. @@ -142,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 9379973..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,13 +79,14 @@ 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) @@ -64,32 +96,28 @@ class TestExtractFilterValidation: """Test validation of --tool, --component, --scope options.""" def test_invalid_tool_rejected(self, tmp_path: Path) -> None: - """Test that an invalid tool name returns error.""" 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: - """Test that an invalid component name returns error.""" 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: - """Test that an invalid scope returns error.""" 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: - """Test that valid tool names are accepted.""" 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: - """Test that valid component names are accepted.""" result = extract_command( project_dir=str(tmp_path), no_ai=True, @@ -103,7 +131,8 @@ def test_valid_component_accepted(self, tmp_path: Path) -> None: def test_filters_pass_to_extractor( self, mock_config: MagicMock, mock_extractor_cls: MagicMock, tmp_path: Path ) -> None: - """Test that tool/component/scope filters are passed to extractor.""" + """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) @@ -112,17 +141,189 @@ def test_filters_pass_to_extractor( extract_command( output=str(output_dir), no_ai=True, - project_dir=str(tmp_path), - tool=["cursor"], - component=["mcp"], - scope="all", + project_dir=str(project_dir), + tool=["claude"], ) mock_extractor.extract.assert_called_once() call_kwargs = mock_extractor.extract.call_args - assert call_kwargs.kwargs["tool_filter"] == ["cursor"] - assert call_kwargs.kwargs["component_filter"] == ["mcp"] - assert call_kwargs.kwargs["scope"] == "all" + # 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: 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