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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions devsync/ai_tools/capability_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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). "
Expand Down
174 changes: 168 additions & 6 deletions devsync/cli/extract.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,123 @@
"""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
from devsync.llm.provider import resolve_provider

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,
name: Optional[str] = None,
no_ai: bool = False,
project_dir: Optional[str] = None,
upgrade: Optional[str] = None,
tool: Optional[list[str]] = None,
component: Optional[list[str]] = None,
scope: str = "project",
dry_run: bool = False,
include_global: bool = False,
) -> int:
"""Extract practices from the current project into a shareable package.

Expand All @@ -30,18 +127,82 @@ def extract_command(
no_ai: Force file-copy mode (no LLM calls).
project_dir: Project directory to extract from. Defaults to cwd.
upgrade: Path to a v1 package to convert to v2 format.
tool: Only extract from these AI tool(s).
component: Only extract these component types.
scope: Detection scope — project, global, or all. Deprecated; use include_global.
dry_run: Show what would be extracted without writing files.
include_global: Include home directory / global configs.

Returns:
Exit code (0 = success).
"""
if upgrade:
return _upgrade_v1_package(upgrade, output=output, name=name, no_ai=no_ai)

# Resolve effective scope: --include-global takes precedence
effective_scope = "project"
if include_global:
effective_scope = "all"
elif scope != "project":
warnings.warn(
"--scope is deprecated, use --include-global instead",
DeprecationWarning,
stacklevel=2,
)
console.print("[yellow]--scope is deprecated. Use --include-global instead.[/yellow]")
effective_scope = scope

# Validate scope
if effective_scope not in ("project", "global", "all"):
console.print(f"[red]Invalid scope: {effective_scope}. Must be project, global, or all.[/red]")
return 1

# Validate tool names
if tool:
from devsync.ai_tools.detector import AIToolDetector

detector = AIToolDetector()
for t in tool:
if not detector.validate_tool_name(t):
console.print(f"[red]Unknown tool: {t}[/red]")
console.print(f"Supported tools: {', '.join(detector.get_tool_names())}")
return 1

# Validate component names
if component:
for c in component:
if c.lower() not in COMPONENT_TYPE_MAP:
valid = sorted(set(COMPONENT_TYPE_MAP.keys()))
console.print(f"[red]Unknown component type: {c}[/red]")
console.print(f"Valid types: {', '.join(valid)}")
return 1

project_path = Path(project_dir) if project_dir else Path.cwd()
if not project_path.is_dir():
console.print(f"[red]Not a directory: {project_path}[/red]")
return 1

# Phase 1: Detection + filtering
comp_detector = ComponentDetector(project_path, scope=effective_scope, tool_filter=tool)
detection = comp_detector.detect_all()

if component:
detection = filter_detection_result(detection, component_filter=component)

# Zero-result handling
if detection.total_count == 0:
_display_zero_result_warning(tool=tool, component=component, include_global=include_global)
return 0

# Display detection summary
_display_detection_summary(detection)

# Dry-run: stop here
if dry_run:
console.print("\n[dim]Dry run — no files written.[/dim]")
return 0

# Phase 2: Extraction
package_name = name or project_path.name
output_path = Path(output) if output else project_path / "devsync-package"

Expand All @@ -63,8 +224,8 @@ def extract_command(
TextColumn("[progress.description]{task.description}"),
console=console,
) as progress:
task = progress.add_task("Scanning project...", total=None)
result = extractor.extract(project_path)
task = progress.add_task("Extracting practices...", total=None)
result = extractor.extract(project_path, detection=detection)
progress.update(task, description="Building package...")

output_path.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -96,12 +257,13 @@ def extract_command(
manifest_path = output_path / "devsync-package.yaml"
manifest_path.write_text(manifest.to_yaml())

# Enhanced output
mode = "[green]AI-powered[/green]" if result.ai_powered else "[yellow]file-copy[/yellow]"
console.print(f"\nExtracted ({mode}):")
console.print(f" Practices: {len(result.practices)}")
console.print(f"\nExtraction complete ({mode})")
console.print(f" Practices generated: {len(result.practices)}")
console.print(f" MCP servers: {len(result.mcp_servers)}")
console.print(f" Source files: {len(result.source_files)}")
console.print(f"\nPackage written to: [cyan]{output_path}[/cyan]")
console.print(f"\n Package written to: [cyan]{output_path}[/cyan]")
return 0


Expand Down
65 changes: 61 additions & 4 deletions devsync/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -70,11 +77,41 @@ def extract(
"--upgrade",
help="Convert a v1 package to v2 format",
),
tool: Optional[list[str]] = typer.Option(
None,
"--tool",
"-t",
help="Only extract from specific AI tool(s). Repeatable.",
),
component: Optional[list[str]] = typer.Option(
None,
"--component",
"-c",
help="Component types to extract: rules, mcp, hooks, commands, skills, workflows, memory. Repeatable.",
),
scope: str = typer.Option(
"project",
"--scope",
"-s",
help="(Deprecated) Detection scope: project, global, or all. Use --include-global instead.",
hidden=True,
),
dry_run: bool = typer.Option(
False,
"--dry-run",
help="Show detected components without writing files or calling the LLM",
),
include_global: bool = typer.Option(
False,
"--include-global",
help="Include home directory / global configs in extraction",
),
) -> None:
"""Extract practices from a project into a shareable package.

Reads your project's AI tool configs (rules, MCP servers, hooks, commands)
and produces a devsync-package.yaml with abstract practice declarations.
Reads your project's AI tool configs (rules, MCP servers, hooks, commands,
skills, workflows, memory files, resources) and produces a devsync-package.yaml
with abstract practice declarations.

Examples:
# AI-powered extraction
Expand All @@ -86,6 +123,21 @@ def extract(
# Custom output and name
devsync extract --output ./my-package --name team-standards

# Extract only from Cursor
devsync extract --tool cursor

# Extract only MCP configs
devsync extract --component mcp

# Preview what would be extracted (no files written)
devsync extract --dry-run

# Include home directory / global configs
devsync extract --include-global

# Combine filters: extract only rules and hooks from Claude Code
devsync extract --tool claude --component rules --component hooks

# Upgrade v1 package to v2
devsync extract --upgrade ./old-package
"""
Expand All @@ -97,6 +149,11 @@ def extract(
no_ai=no_ai,
project_dir=project_dir,
upgrade=upgrade,
tool=tool,
component=component,
scope=scope,
dry_run=dry_run,
include_global=include_global,
)
raise typer.Exit(code=exit_code)

Expand Down
Loading
Loading