From ebb78911a3bd1bc2170ca771db09b4dc7ade745e Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:40:33 -0500 Subject: [PATCH 01/14] feat(core): add LLM provider abstraction, practice models, and prompt templates (#63, #64, #65) Phase 0 of v2.0: HTTP-only LLM providers (Anthropic, OpenAI, OpenRouter), PracticeDeclaration/MCPDeclaration models, prompt templates, and response models with full test coverage. --- devsync/core/practice.py | 174 +++++++++++++++++++++ devsync/llm/__init__.py | 13 ++ devsync/llm/anthropic.py | 98 ++++++++++++ devsync/llm/config.py | 92 +++++++++++ devsync/llm/openai_provider.py | 98 ++++++++++++ devsync/llm/openrouter.py | 100 ++++++++++++ devsync/llm/prompts.py | 116 ++++++++++++++ devsync/llm/provider.py | 137 +++++++++++++++++ devsync/llm/response_models.py | 187 +++++++++++++++++++++++ pyproject.toml | 1 + tests/unit/core/__init__.py | 0 tests/unit/core/test_practice.py | 201 +++++++++++++++++++++++++ tests/unit/llm/__init__.py | 0 tests/unit/llm/test_anthropic.py | 126 ++++++++++++++++ tests/unit/llm/test_config.py | 78 ++++++++++ tests/unit/llm/test_openai.py | 85 +++++++++++ tests/unit/llm/test_openrouter.py | 70 +++++++++ tests/unit/llm/test_prompts.py | 60 ++++++++ tests/unit/llm/test_provider.py | 91 +++++++++++ tests/unit/llm/test_response_models.py | 130 ++++++++++++++++ 20 files changed, 1857 insertions(+) create mode 100644 devsync/core/practice.py create mode 100644 devsync/llm/__init__.py create mode 100644 devsync/llm/anthropic.py create mode 100644 devsync/llm/config.py create mode 100644 devsync/llm/openai_provider.py create mode 100644 devsync/llm/openrouter.py create mode 100644 devsync/llm/prompts.py create mode 100644 devsync/llm/provider.py create mode 100644 devsync/llm/response_models.py create mode 100644 tests/unit/core/__init__.py create mode 100644 tests/unit/core/test_practice.py create mode 100644 tests/unit/llm/__init__.py create mode 100644 tests/unit/llm/test_anthropic.py create mode 100644 tests/unit/llm/test_config.py create mode 100644 tests/unit/llm/test_openai.py create mode 100644 tests/unit/llm/test_openrouter.py create mode 100644 tests/unit/llm/test_prompts.py create mode 100644 tests/unit/llm/test_provider.py create mode 100644 tests/unit/llm/test_response_models.py diff --git a/devsync/core/practice.py b/devsync/core/practice.py new file mode 100644 index 0000000..765716f --- /dev/null +++ b/devsync/core/practice.py @@ -0,0 +1,174 @@ +"""Practice declaration models for v2 AI-powered config distribution.""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class CredentialSpec: + """Specification for a credential required by an MCP server. + + Attributes: + name: Environment variable name (e.g., 'GITHUB_PERSONAL_ACCESS_TOKEN'). + description: Human-readable description for prompting. + required: Whether the credential is mandatory. + default: Default value hint (not the actual secret). + """ + + name: str + description: str + required: bool = True + default: Optional[str] = None + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("CredentialSpec name cannot be empty") + if not self.description: + raise ValueError("CredentialSpec description cannot be empty") + + def to_dict(self) -> dict: + result: dict = { + "name": self.name, + "description": self.description, + "required": self.required, + } + if self.default is not None: + result["default"] = self.default + return result + + @classmethod + def from_dict(cls, data: dict) -> "CredentialSpec": + return cls( + name=data["name"], + description=data["description"], + required=data.get("required", True), + default=data.get("default"), + ) + + +@dataclass +class PracticeDeclaration: + """An abstract coding practice extracted from project configs. + + Unlike v1 file copies, practices are semantic declarations of intent + that can be adapted to any AI tool's format. + + Attributes: + name: Short identifier (e.g., 'type-safety'). + intent: One-line description of what this practice enforces. + principles: List of specific rules/guidelines. + enforcement_patterns: How to enforce (CI checks, linting, etc.). + examples: Code examples demonstrating the practice. + tags: Categorization tags. + source_file: Original file this was extracted from (for reference). + raw_content: Original file content (fallback for no-AI mode). + """ + + name: str + intent: str + principles: list[str] = field(default_factory=list) + enforcement_patterns: list[str] = field(default_factory=list) + examples: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + source_file: Optional[str] = None + raw_content: Optional[str] = None + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("PracticeDeclaration name cannot be empty") + if not self.intent: + raise ValueError("PracticeDeclaration intent cannot be empty") + + def to_dict(self) -> dict: + result: dict = { + "name": self.name, + "intent": self.intent, + } + if self.principles: + result["principles"] = self.principles + if self.enforcement_patterns: + result["enforcement_patterns"] = self.enforcement_patterns + if self.examples: + result["examples"] = self.examples + if self.tags: + result["tags"] = self.tags + if self.source_file: + result["source_file"] = self.source_file + if self.raw_content: + result["raw_content"] = self.raw_content + return result + + @classmethod + def from_dict(cls, data: dict) -> "PracticeDeclaration": + return cls( + name=data["name"], + intent=data["intent"], + principles=data.get("principles", []), + enforcement_patterns=data.get("enforcement_patterns", []), + examples=data.get("examples", []), + tags=data.get("tags", []), + source_file=data.get("source_file"), + raw_content=data.get("raw_content"), + ) + + +@dataclass +class MCPDeclaration: + """Declaration for an MCP server configuration. + + Credentials are stripped — only metadata and credential specs are stored. + + Attributes: + name: Server identifier (e.g., 'github-mcp'). + description: What the server provides. + protocol: Communication protocol ('stdio' or 'sse'). + command: Executable command (e.g., 'npx'). + args: Command arguments. + env_vars: Non-secret environment variables. + credentials: Required credential specifications. + """ + + name: str + description: str + protocol: str = "stdio" + command: str = "" + args: list[str] = field(default_factory=list) + env_vars: dict[str, str] = field(default_factory=dict) + credentials: list[CredentialSpec] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("MCPDeclaration name cannot be empty") + if not self.description: + raise ValueError("MCPDeclaration description cannot be empty") + if self.protocol not in ("stdio", "sse"): + raise ValueError(f"MCPDeclaration protocol must be 'stdio' or 'sse', got '{self.protocol}'") + + def to_dict(self) -> dict: + result: dict = { + "name": self.name, + "description": self.description, + "protocol": self.protocol, + } + if self.command: + result["command"] = self.command + if self.args: + result["args"] = self.args + if self.env_vars: + result["env_vars"] = self.env_vars + if self.credentials: + result["credentials"] = [c.to_dict() for c in self.credentials] + return result + + @classmethod + def from_dict(cls, data: dict) -> "MCPDeclaration": + credentials = [CredentialSpec.from_dict(c) for c in data.get("credentials", [])] + return cls( + name=data["name"], + description=data["description"], + protocol=data.get("protocol", "stdio"), + command=data.get("command", ""), + args=data.get("args", []), + env_vars=data.get("env_vars", {}), + credentials=credentials, + ) diff --git a/devsync/llm/__init__.py b/devsync/llm/__init__.py new file mode 100644 index 0000000..a1c42df --- /dev/null +++ b/devsync/llm/__init__.py @@ -0,0 +1,13 @@ +"""LLM provider abstraction for AI-powered config operations.""" + +from devsync.llm.config import LLMConfig, load_config, save_config +from devsync.llm.provider import LLMProvider, LLMResponse, resolve_provider + +__all__ = [ + "LLMConfig", + "LLMProvider", + "LLMResponse", + "load_config", + "resolve_provider", + "save_config", +] diff --git a/devsync/llm/anthropic.py b/devsync/llm/anthropic.py new file mode 100644 index 0000000..b9257be --- /dev/null +++ b/devsync/llm/anthropic.py @@ -0,0 +1,98 @@ +"""Anthropic Claude provider using HTTP-only calls.""" + +import json +from typing import Optional + +import httpx + +from devsync.llm.provider import LLMProvider, LLMProviderError, LLMResponse + +_API_URL = "https://api.anthropic.com/v1/messages" +_API_VERSION = "2023-06-01" +_DEFAULT_MODEL = "claude-sonnet-4-20250514" + + +class AnthropicProvider(LLMProvider): + """Anthropic Claude provider via Messages API. + + Uses httpx for HTTP calls — no anthropic SDK dependency. + """ + + def __init__(self, api_key: str, model: Optional[str] = None): + self._api_key = api_key + self._model = model or _DEFAULT_MODEL + + @property + def name(self) -> str: + return "anthropic" + + @property + def default_model(self) -> str: + return self._model + + def complete( + self, + prompt: str, + *, + system: str = "", + model: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + ) -> LLMResponse: + model_id = model or self._model + headers = { + "x-api-key": self._api_key, + "anthropic-version": _API_VERSION, + "content-type": "application/json", + } + + body: dict = { + "model": model_id, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": [{"role": "user", "content": prompt}], + } + if system: + body["system"] = system + + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(_API_URL, headers=headers, json=body) + except httpx.HTTPError as e: + raise LLMProviderError(f"HTTP error calling Anthropic API: {e}") from e + + raw = response.json() + + if response.status_code != 200: + error_msg = raw.get("error", {}).get("message", response.text) + raise LLMProviderError( + f"Anthropic API error: {error_msg}", + status_code=response.status_code, + raw_response=raw, + ) + + content = "" + for block in raw.get("content", []): + if block.get("type") == "text": + content += block.get("text", "") + + usage_raw = raw.get("usage", {}) + usage = { + "prompt_tokens": usage_raw.get("input_tokens", 0), + "completion_tokens": usage_raw.get("output_tokens", 0), + "total_tokens": usage_raw.get("input_tokens", 0) + usage_raw.get("output_tokens", 0), + } + + return LLMResponse( + content=content, + model=raw.get("model", model_id), + usage=usage, + raw_response=raw, + ) + + def validate_api_key(self) -> bool: + try: + self.complete("Say 'ok'.", max_tokens=10) + return True + except LLMProviderError: + return False diff --git a/devsync/llm/config.py b/devsync/llm/config.py new file mode 100644 index 0000000..d71952a --- /dev/null +++ b/devsync/llm/config.py @@ -0,0 +1,92 @@ +"""LLM configuration management. + +Stores provider and model preferences in ~/.devsync/config.yaml. +API keys are NEVER stored — only env var names for reference. +""" + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + + +_CONFIG_DIR = Path.home() / ".devsync" +_CONFIG_FILE = _CONFIG_DIR / "config.yaml" + + +@dataclass +class LLMConfig: + """LLM configuration (no secrets stored). + + Attributes: + provider: Preferred provider name ('anthropic', 'openai', 'openrouter'). + model: Preferred model ID override. + env_var: Name of the env var holding the API key (for user reference). + """ + + provider: Optional[str] = None + model: Optional[str] = None + env_var: Optional[str] = None + + def to_dict(self) -> dict: + result: dict = {} + if self.provider: + result["provider"] = self.provider + if self.model: + result["model"] = self.model + if self.env_var: + result["env_var"] = self.env_var + return result + + @classmethod + def from_dict(cls, data: dict) -> "LLMConfig": + return cls( + provider=data.get("provider"), + model=data.get("model"), + env_var=data.get("env_var"), + ) + + +def load_config(config_path: Optional[Path] = None) -> LLMConfig: + """Load LLM config from disk. + + Args: + config_path: Override config file path (for testing). + + Returns: + LLMConfig instance (empty if file doesn't exist). + """ + path = config_path or _CONFIG_FILE + if not path.exists(): + return LLMConfig() + + with open(path) as f: + data = yaml.safe_load(f) or {} + + llm_data = data.get("llm", {}) + return LLMConfig.from_dict(llm_data) + + +def save_config(config: LLMConfig, config_path: Optional[Path] = None) -> None: + """Save LLM config to disk. + + Merges with existing config to preserve other sections. + API keys are NEVER written to this file. + + Args: + config: LLMConfig to save. + config_path: Override config file path (for testing). + """ + path = config_path or _CONFIG_FILE + path.parent.mkdir(parents=True, exist_ok=True) + + existing: dict = {} + if path.exists(): + with open(path) as f: + existing = yaml.safe_load(f) or {} + + existing["llm"] = config.to_dict() + + with open(path, "w") as f: + yaml.dump(existing, f, default_flow_style=False, sort_keys=False) diff --git a/devsync/llm/openai_provider.py b/devsync/llm/openai_provider.py new file mode 100644 index 0000000..9d679d6 --- /dev/null +++ b/devsync/llm/openai_provider.py @@ -0,0 +1,98 @@ +"""OpenAI provider using HTTP-only calls.""" + +from typing import Optional + +import httpx + +from devsync.llm.provider import LLMProvider, LLMProviderError, LLMResponse + +_API_URL = "https://api.openai.com/v1/chat/completions" +_DEFAULT_MODEL = "gpt-4o" + + +class OpenAIProvider(LLMProvider): + """OpenAI provider via Chat Completions API. + + Uses httpx for HTTP calls — no openai SDK dependency. + """ + + def __init__(self, api_key: str, model: Optional[str] = None): + self._api_key = api_key + self._model = model or _DEFAULT_MODEL + + @property + def name(self) -> str: + return "openai" + + @property + def default_model(self) -> str: + return self._model + + def complete( + self, + prompt: str, + *, + system: str = "", + model: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + ) -> LLMResponse: + model_id = model or self._model + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + messages: list[dict[str, str]] = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + body = { + "model": model_id, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": messages, + } + + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(_API_URL, headers=headers, json=body) + except httpx.HTTPError as e: + raise LLMProviderError(f"HTTP error calling OpenAI API: {e}") from e + + raw = response.json() + + if response.status_code != 200: + error_msg = raw.get("error", {}).get("message", response.text) + raise LLMProviderError( + f"OpenAI API error: {error_msg}", + status_code=response.status_code, + raw_response=raw, + ) + + content = "" + choices = raw.get("choices", []) + if choices: + content = choices[0].get("message", {}).get("content", "") + + usage_raw = raw.get("usage", {}) + usage = { + "prompt_tokens": usage_raw.get("prompt_tokens", 0), + "completion_tokens": usage_raw.get("completion_tokens", 0), + "total_tokens": usage_raw.get("total_tokens", 0), + } + + return LLMResponse( + content=content, + model=raw.get("model", model_id), + usage=usage, + raw_response=raw, + ) + + def validate_api_key(self) -> bool: + try: + self.complete("Say 'ok'.", max_tokens=10) + return True + except LLMProviderError: + return False diff --git a/devsync/llm/openrouter.py b/devsync/llm/openrouter.py new file mode 100644 index 0000000..a63fb52 --- /dev/null +++ b/devsync/llm/openrouter.py @@ -0,0 +1,100 @@ +"""OpenRouter provider using HTTP-only calls (OpenAI-compatible API).""" + +from typing import Optional + +import httpx + +from devsync.llm.provider import LLMProvider, LLMProviderError, LLMResponse + +_API_URL = "https://openrouter.ai/api/v1/chat/completions" +_DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514" + + +class OpenRouterProvider(LLMProvider): + """OpenRouter provider via OpenAI-compatible Chat Completions API. + + Uses httpx for HTTP calls — no SDK dependency. + """ + + def __init__(self, api_key: str, model: Optional[str] = None): + self._api_key = api_key + self._model = model or _DEFAULT_MODEL + + @property + def name(self) -> str: + return "openrouter" + + @property + def default_model(self) -> str: + return self._model + + def complete( + self, + prompt: str, + *, + system: str = "", + model: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + ) -> LLMResponse: + model_id = model or self._model + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/troylar/devsync", + "X-Title": "DevSync", + } + + messages: list[dict[str, str]] = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + body = { + "model": model_id, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": messages, + } + + try: + with httpx.Client(timeout=120.0) as client: + response = client.post(_API_URL, headers=headers, json=body) + except httpx.HTTPError as e: + raise LLMProviderError(f"HTTP error calling OpenRouter API: {e}") from e + + raw = response.json() + + if response.status_code != 200: + error_msg = raw.get("error", {}).get("message", response.text) + raise LLMProviderError( + f"OpenRouter API error: {error_msg}", + status_code=response.status_code, + raw_response=raw, + ) + + content = "" + choices = raw.get("choices", []) + if choices: + content = choices[0].get("message", {}).get("content", "") + + usage_raw = raw.get("usage", {}) + usage = { + "prompt_tokens": usage_raw.get("prompt_tokens", 0), + "completion_tokens": usage_raw.get("completion_tokens", 0), + "total_tokens": usage_raw.get("total_tokens", 0), + } + + return LLMResponse( + content=content, + model=raw.get("model", model_id), + usage=usage, + raw_response=raw, + ) + + def validate_api_key(self) -> bool: + try: + self.complete("Say 'ok'.", max_tokens=10) + return True + except LLMProviderError: + return False diff --git a/devsync/llm/prompts.py b/devsync/llm/prompts.py new file mode 100644 index 0000000..f262de8 --- /dev/null +++ b/devsync/llm/prompts.py @@ -0,0 +1,116 @@ +"""Prompt templates for LLM-powered extraction and adaptation.""" + +SYSTEM_PROMPT = """\ +You are a coding standards expert. You analyze AI coding assistant configurations \ +and extract structured practice declarations. Always respond with valid JSON.""" + +EXTRACT_PRACTICES_PROMPT = """\ +Analyze the following instruction files from a software project and extract abstract \ +coding practice declarations. + +For each distinct practice found, produce a JSON object with: +- "name": short kebab-case identifier +- "intent": one-line description of what this practice enforces +- "principles": list of specific rules/guidelines +- "enforcement_patterns": how to enforce (CI checks, linting config, etc.) +- "examples": code examples if present +- "tags": categorization tags + +Input files: +{files_content} + +Respond with a JSON object: +{{ + "practices": [ + {{ + "name": "...", + "intent": "...", + "principles": ["..."], + "enforcement_patterns": ["..."], + "examples": ["..."], + "tags": ["..."] + }} + ] +}}""" + +EXTRACT_MCP_PROMPT = """\ +Analyze the following MCP server configuration and extract a structured declaration. + +Strip all credential VALUES but keep credential NAMES and descriptions. + +Input configuration: +{mcp_config} + +Respond with a JSON object: +{{ + "name": "server-name", + "description": "what this server provides", + "protocol": "stdio", + "command": "executable", + "args": ["arg1", "arg2"], + "env_vars": {{"NON_SECRET_VAR": "value"}}, + "credentials": [ + {{ + "name": "ENV_VAR_NAME", + "description": "what this credential is for", + "required": true + }} + ] +}}""" + +ADAPT_PRACTICE_PROMPT = """\ +You are adapting a coding practice for installation into a project that already has \ +existing rules. + +Incoming practice: +{practice_json} + +Existing rules in the target project: +{existing_rules} + +Target AI tool: {tool_name} + +Determine the best adaptation strategy: +1. "install" — no conflict, install as-is +2. "merge" — overlapping content, produce merged version +3. "skip" — existing rules already cover this practice + +Respond with a JSON object: +{{ + "action": "install|merge|skip", + "reason": "explanation", + "merged_content": "merged instruction text (only if action=merge)", + "file_name": "suggested-filename.md" +}}""" + +MERGE_PRACTICES_PROMPT = """\ +Merge the following two instruction documents into a single coherent document. +Preserve all unique rules from both. Remove duplicates. Resolve contradictions \ +by preferring the incoming practice (it represents the team's latest standards). + +Existing document: +{existing_content} + +Incoming practice: +{incoming_content} + +Respond with a JSON object: +{{ + "merged_content": "the merged instruction text", + "changes_summary": "brief description of what was merged/changed" +}}""" + + +def format_files_for_extraction(files: dict[str, str]) -> str: + """Format a dict of {filename: content} for the extraction prompt. + + Args: + files: Mapping of file paths to their content. + + Returns: + Formatted string with file separators. + """ + parts = [] + for path, content in files.items(): + parts.append(f"--- {path} ---\n{content}\n") + return "\n".join(parts) diff --git a/devsync/llm/provider.py b/devsync/llm/provider.py new file mode 100644 index 0000000..ce5ea79 --- /dev/null +++ b/devsync/llm/provider.py @@ -0,0 +1,137 @@ +"""Abstract LLM provider and provider resolution.""" + +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class LLMResponse: + """Response from an LLM provider. + + Attributes: + content: The text content of the response. + model: The model that generated the response. + usage: Token usage dict with prompt_tokens, completion_tokens, total_tokens. + raw_response: The raw HTTP response dict for debugging. + """ + + content: str + model: str + usage: dict[str, int] = field(default_factory=dict) + raw_response: dict = field(default_factory=dict) + + +class LLMProvider(ABC): + """Abstract base class for LLM providers. + + All providers use HTTP-only calls (no SDK dependencies) via httpx. + """ + + @property + @abstractmethod + def name(self) -> str: + """Provider name (e.g., 'anthropic', 'openai', 'openrouter').""" + + @property + @abstractmethod + def default_model(self) -> str: + """Default model ID for this provider.""" + + @abstractmethod + def complete( + self, + prompt: str, + *, + system: str = "", + model: Optional[str] = None, + max_tokens: int = 4096, + temperature: float = 0.0, + ) -> LLMResponse: + """Send a completion request to the LLM. + + Args: + prompt: The user message/prompt. + system: Optional system message. + model: Model override (uses default_model if None). + max_tokens: Maximum tokens in the response. + temperature: Sampling temperature (0.0 = deterministic). + + Returns: + LLMResponse with the generated content. + + Raises: + LLMProviderError: If the API call fails. + """ + + @abstractmethod + def validate_api_key(self) -> bool: + """Validate the API key with a minimal test call. + + Returns: + True if the key is valid, False otherwise. + """ + + +class LLMProviderError(Exception): + """Raised when an LLM API call fails.""" + + def __init__(self, message: str, status_code: Optional[int] = None, raw_response: Optional[dict] = None): + super().__init__(message) + self.status_code = status_code + self.raw_response = raw_response + + +_PROVIDER_ENV_VARS = [ + ("anthropic", "ANTHROPIC_API_KEY"), + ("openai", "OPENAI_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), +] + + +def resolve_provider( + preferred_provider: Optional[str] = None, + preferred_model: Optional[str] = None, +) -> Optional[LLMProvider]: + """Resolve the best available LLM provider. + + Checks env vars in priority order: ANTHROPIC_API_KEY → OPENAI_API_KEY → OPENROUTER_API_KEY. + If preferred_provider is set, only that provider is checked. + + Args: + preferred_provider: Optional provider name to use ('anthropic', 'openai', 'openrouter'). + preferred_model: Optional model ID override. + + Returns: + An LLMProvider instance, or None if no API key is found. + """ + from devsync.llm.anthropic import AnthropicProvider + from devsync.llm.openai_provider import OpenAIProvider + from devsync.llm.openrouter import OpenRouterProvider + + provider_map: dict[str, type[LLMProvider]] = { + "anthropic": AnthropicProvider, + "openai": OpenAIProvider, + "openrouter": OpenRouterProvider, + } + + if preferred_provider: + provider_cls = provider_map.get(preferred_provider) + if not provider_cls: + return None + env_var = next((ev for name, ev in _PROVIDER_ENV_VARS if name == preferred_provider), None) + if not env_var: + return None + api_key = os.environ.get(env_var) + if not api_key: + return None + return provider_cls(api_key=api_key, model=preferred_model) # type: ignore[call-arg] + + for provider_name, env_var in _PROVIDER_ENV_VARS: + api_key = os.environ.get(env_var) + if api_key: + provider_cls = provider_map[provider_name] + return provider_cls(api_key=api_key, model=preferred_model) # type: ignore[call-arg] + + return None diff --git a/devsync/llm/response_models.py b/devsync/llm/response_models.py new file mode 100644 index 0000000..b478afd --- /dev/null +++ b/devsync/llm/response_models.py @@ -0,0 +1,187 @@ +"""Structured response types for LLM outputs.""" + +import json +from dataclasses import dataclass, field +from typing import Optional + +from devsync.core.practice import MCPDeclaration, PracticeDeclaration + + +@dataclass +class ExtractionResult: + """Result of AI-powered practice extraction. + + Attributes: + practices: Extracted practice declarations. + mcp_servers: Extracted MCP server declarations. + source_files: Original files that were analyzed. + ai_powered: Whether AI was used (False = fallback mode). + """ + + practices: list[PracticeDeclaration] = field(default_factory=list) + mcp_servers: list[MCPDeclaration] = field(default_factory=list) + source_files: list[str] = field(default_factory=list) + ai_powered: bool = True + + def to_dict(self) -> dict: + return { + "practices": [p.to_dict() for p in self.practices], + "mcp_servers": [m.to_dict() for m in self.mcp_servers], + "source_files": self.source_files, + "ai_powered": self.ai_powered, + } + + +@dataclass +class AdaptationAction: + """A single adaptation action for one practice/file. + + Attributes: + action: One of 'install', 'merge', 'skip'. + practice_name: Name of the practice being adapted. + reason: Why this action was chosen. + file_name: Target file name for installation. + content: The content to write (original or merged). + """ + + action: str + practice_name: str + reason: str + file_name: str = "" + content: str = "" + + def to_dict(self) -> dict: + return { + "action": self.action, + "practice_name": self.practice_name, + "reason": self.reason, + "file_name": self.file_name, + "content": self.content, + } + + +@dataclass +class AdaptationPlan: + """Plan for adapting practices to a target project. + + Presented to the user for review before execution. + + Attributes: + actions: List of adaptation actions. + target_tools: AI tools to install to. + ai_powered: Whether AI was used for adaptation. + """ + + actions: list[AdaptationAction] = field(default_factory=list) + target_tools: list[str] = field(default_factory=list) + ai_powered: bool = True + + @property + def installs(self) -> list[AdaptationAction]: + return [a for a in self.actions if a.action == "install"] + + @property + def merges(self) -> list[AdaptationAction]: + return [a for a in self.actions if a.action == "merge"] + + @property + def skips(self) -> list[AdaptationAction]: + return [a for a in self.actions if a.action == "skip"] + + def to_dict(self) -> dict: + return { + "actions": [a.to_dict() for a in self.actions], + "target_tools": self.target_tools, + "ai_powered": self.ai_powered, + } + + +@dataclass +class MergeDecision: + """Result of merging two instruction documents. + + Attributes: + merged_content: The merged instruction text. + changes_summary: Brief description of what changed. + """ + + merged_content: str + changes_summary: str + + def to_dict(self) -> dict: + return { + "merged_content": self.merged_content, + "changes_summary": self.changes_summary, + } + + +def parse_extraction_response(raw_json: str) -> list[PracticeDeclaration]: + """Parse LLM extraction response into PracticeDeclaration list. + + Args: + raw_json: JSON string from LLM response. + + Returns: + List of PracticeDeclaration objects. + + Raises: + ValueError: If JSON is invalid or missing required fields. + """ + try: + data = json.loads(raw_json) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in LLM response: {e}") from e + + practices = [] + for item in data.get("practices", []): + practices.append(PracticeDeclaration.from_dict(item)) + return practices + + +def parse_adaptation_response(raw_json: str) -> AdaptationAction: + """Parse LLM adaptation response into an AdaptationAction. + + Args: + raw_json: JSON string from LLM response. + + Returns: + AdaptationAction object. + + Raises: + ValueError: If JSON is invalid or missing required fields. + """ + try: + data = json.loads(raw_json) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in LLM response: {e}") from e + + return AdaptationAction( + action=data.get("action", "skip"), + practice_name="", + reason=data.get("reason", ""), + file_name=data.get("file_name", ""), + content=data.get("merged_content", ""), + ) + + +def parse_merge_response(raw_json: str) -> MergeDecision: + """Parse LLM merge response into a MergeDecision. + + Args: + raw_json: JSON string from LLM response. + + Returns: + MergeDecision object. + + Raises: + ValueError: If JSON is invalid or missing required fields. + """ + try: + data = json.loads(raw_json) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in LLM response: {e}") from e + + return MergeDecision( + merged_content=data.get("merged_content", ""), + changes_summary=data.get("changes_summary", ""), + ) diff --git a/pyproject.toml b/pyproject.toml index 2d07185..b4c51eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "textual>=6.0.0", "GitPython>=3.1.45", "python-dotenv>=1.0.0", + "httpx>=0.27.0", ] [project.optional-dependencies] diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/core/test_practice.py b/tests/unit/core/test_practice.py new file mode 100644 index 0000000..43846ab --- /dev/null +++ b/tests/unit/core/test_practice.py @@ -0,0 +1,201 @@ +"""Tests for practice declaration models.""" + +import pytest +import yaml + +from devsync.core.practice import CredentialSpec, MCPDeclaration, PracticeDeclaration + + +class TestCredentialSpec: + def test_create(self) -> None: + spec = CredentialSpec(name="API_KEY", description="The API key") + assert spec.name == "API_KEY" + assert spec.required is True + assert spec.default is None + + def test_empty_name_raises(self) -> None: + with pytest.raises(ValueError, match="name cannot be empty"): + CredentialSpec(name="", description="test") + + def test_empty_description_raises(self) -> None: + with pytest.raises(ValueError, match="description cannot be empty"): + CredentialSpec(name="KEY", description="") + + def test_to_dict(self) -> None: + spec = CredentialSpec(name="TOKEN", description="Auth token", required=False, default="xxx") + d = spec.to_dict() + assert d["name"] == "TOKEN" + assert d["required"] is False + assert d["default"] == "xxx" + + def test_from_dict(self) -> None: + data = {"name": "KEY", "description": "A key", "required": True} + spec = CredentialSpec.from_dict(data) + assert spec.name == "KEY" + assert spec.default is None + + def test_roundtrip(self) -> None: + original = CredentialSpec(name="SECRET", description="Secret value", required=False, default="default") + restored = CredentialSpec.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.required == original.required + assert restored.default == original.default + + +class TestPracticeDeclaration: + def test_create_minimal(self) -> None: + p = PracticeDeclaration(name="type-safety", intent="Enforce type hints") + assert p.name == "type-safety" + assert p.principles == [] + + def test_empty_name_raises(self) -> None: + with pytest.raises(ValueError, match="name cannot be empty"): + PracticeDeclaration(name="", intent="test") + + def test_empty_intent_raises(self) -> None: + with pytest.raises(ValueError, match="intent cannot be empty"): + PracticeDeclaration(name="test", intent="") + + def test_create_full(self) -> None: + p = PracticeDeclaration( + name="type-safety", + intent="Enforce strict type hints", + principles=["All functions have type hints"], + enforcement_patterns=["Run mypy strict"], + examples=["def foo(x: int) -> str:"], + tags=["python", "typing"], + source_file="rules/types.md", + raw_content="# Type Safety\n...", + ) + assert len(p.principles) == 1 + assert p.tags == ["python", "typing"] + + def test_to_dict_minimal(self) -> None: + p = PracticeDeclaration(name="test", intent="Test practice") + d = p.to_dict() + assert d == {"name": "test", "intent": "Test practice"} + + def test_to_dict_full(self) -> None: + p = PracticeDeclaration( + name="test", + intent="Test", + principles=["rule1"], + tags=["tag1"], + ) + d = p.to_dict() + assert "principles" in d + assert "tags" in d + assert "enforcement_patterns" not in d # empty list omitted + + def test_from_dict(self) -> None: + data = { + "name": "code-style", + "intent": "Enforce code style", + "principles": ["Use black", "Line length 120"], + "tags": ["python"], + } + p = PracticeDeclaration.from_dict(data) + assert p.name == "code-style" + assert len(p.principles) == 2 + + def test_roundtrip(self) -> None: + original = PracticeDeclaration( + name="testing", + intent="Ensure test coverage", + principles=["80% minimum", "Unit tests for public API"], + enforcement_patterns=["pytest-cov"], + examples=["def test_foo(): ..."], + tags=["testing"], + source_file="rules/testing.md", + ) + restored = PracticeDeclaration.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.principles == original.principles + assert restored.tags == original.tags + + def test_yaml_roundtrip(self) -> None: + original = PracticeDeclaration( + name="security", + intent="Enforce security patterns", + principles=["No eval()", "No hardcoded secrets"], + tags=["security"], + ) + yaml_str = yaml.dump(original.to_dict(), default_flow_style=False) + loaded = yaml.safe_load(yaml_str) + restored = PracticeDeclaration.from_dict(loaded) + assert restored.name == original.name + assert restored.principles == original.principles + + +class TestMCPDeclaration: + def test_create_minimal(self) -> None: + m = MCPDeclaration(name="github", description="GitHub API") + assert m.protocol == "stdio" + assert m.credentials == [] + + def test_empty_name_raises(self) -> None: + with pytest.raises(ValueError, match="name cannot be empty"): + MCPDeclaration(name="", description="test") + + def test_invalid_protocol_raises(self) -> None: + with pytest.raises(ValueError, match="protocol must be"): + MCPDeclaration(name="test", description="test", protocol="grpc") + + def test_create_full(self) -> None: + m = MCPDeclaration( + name="github-mcp", + description="GitHub API access", + protocol="stdio", + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + env_vars={"NODE_ENV": "production"}, + credentials=[ + CredentialSpec(name="GITHUB_TOKEN", description="GitHub PAT"), + ], + ) + assert m.command == "npx" + assert len(m.credentials) == 1 + + def test_to_dict(self) -> None: + m = MCPDeclaration( + name="test", + description="Test server", + command="node", + args=["server.js"], + credentials=[CredentialSpec(name="KEY", description="API key")], + ) + d = m.to_dict() + assert d["command"] == "node" + assert len(d["credentials"]) == 1 + + def test_from_dict(self) -> None: + data = { + "name": "github", + "description": "GitHub", + "protocol": "stdio", + "command": "npx", + "args": ["-y", "server"], + "credentials": [{"name": "TOKEN", "description": "Token"}], + } + m = MCPDeclaration.from_dict(data) + assert m.command == "npx" + assert len(m.credentials) == 1 + assert m.credentials[0].name == "TOKEN" + + def test_roundtrip(self) -> None: + original = MCPDeclaration( + name="db", + description="Database access", + protocol="sse", + command="python", + args=["mcp_server.py"], + env_vars={"DB_HOST": "localhost"}, + credentials=[ + CredentialSpec(name="DB_PASSWORD", description="Database password"), + ], + ) + restored = MCPDeclaration.from_dict(original.to_dict()) + assert restored.name == original.name + assert restored.protocol == original.protocol + assert restored.env_vars == original.env_vars + assert len(restored.credentials) == 1 diff --git a/tests/unit/llm/__init__.py b/tests/unit/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/llm/test_anthropic.py b/tests/unit/llm/test_anthropic.py new file mode 100644 index 0000000..04df57b --- /dev/null +++ b/tests/unit/llm/test_anthropic.py @@ -0,0 +1,126 @@ +"""Tests for Anthropic provider with mocked HTTP responses.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.llm.anthropic import AnthropicProvider +from devsync.llm.provider import LLMProviderError + + +def _mock_response(status_code: int, json_data: dict) -> MagicMock: + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = json_data + mock.text = str(json_data) + return mock + + +class TestAnthropicProvider: + def test_name(self) -> None: + provider = AnthropicProvider(api_key="test-key") + assert provider.name == "anthropic" + + def test_default_model(self) -> None: + provider = AnthropicProvider(api_key="test-key") + assert "claude" in provider.default_model + + def test_custom_model(self) -> None: + provider = AnthropicProvider(api_key="test-key", model="claude-haiku-4-5-20251001") + assert provider.default_model == "claude-haiku-4-5-20251001" + + @patch("devsync.llm.anthropic.httpx.Client") + def test_complete_success(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "content": [{"type": "text", "text": "Hello world"}], + "model": "claude-sonnet-4-20250514", + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = AnthropicProvider(api_key="test-key") + result = provider.complete("Say hello") + + assert result.content == "Hello world" + assert result.model == "claude-sonnet-4-20250514" + assert result.usage["prompt_tokens"] == 10 + assert result.usage["completion_tokens"] == 5 + assert result.usage["total_tokens"] == 15 + + @patch("devsync.llm.anthropic.httpx.Client") + def test_complete_with_system_message(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "content": [{"type": "text", "text": "ok"}], + "model": "claude-sonnet-4-20250514", + "usage": {"input_tokens": 5, "output_tokens": 1}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = AnthropicProvider(api_key="test-key") + provider.complete("test", system="You are a helper") + + call_args = mock_client.post.call_args + body = call_args[1]["json"] + assert body["system"] == "You are a helper" + + @patch("devsync.llm.anthropic.httpx.Client") + def test_complete_api_error(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 401, + {"error": {"message": "Invalid API key"}}, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = AnthropicProvider(api_key="bad-key") + with pytest.raises(LLMProviderError) as exc_info: + provider.complete("test") + assert exc_info.value.status_code == 401 + + @patch("devsync.llm.anthropic.httpx.Client") + def test_validate_api_key_success(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "content": [{"type": "text", "text": "ok"}], + "model": "claude-sonnet-4-20250514", + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = AnthropicProvider(api_key="good-key") + assert provider.validate_api_key() is True + + @patch("devsync.llm.anthropic.httpx.Client") + def test_validate_api_key_failure(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response(401, {"error": {"message": "Invalid"}}) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = AnthropicProvider(api_key="bad-key") + assert provider.validate_api_key() is False diff --git a/tests/unit/llm/test_config.py b/tests/unit/llm/test_config.py new file mode 100644 index 0000000..22eff45 --- /dev/null +++ b/tests/unit/llm/test_config.py @@ -0,0 +1,78 @@ +"""Tests for LLM configuration management.""" + +from pathlib import Path + +import pytest + +from devsync.llm.config import LLMConfig, load_config, save_config + + +class TestLLMConfig: + def test_empty_config(self) -> None: + config = LLMConfig() + assert config.provider is None + assert config.model is None + assert config.env_var is None + + def test_to_dict_empty(self) -> None: + config = LLMConfig() + assert config.to_dict() == {} + + def test_to_dict_full(self) -> None: + config = LLMConfig(provider="anthropic", model="claude-haiku-4-5-20251001", env_var="ANTHROPIC_API_KEY") + d = config.to_dict() + assert d["provider"] == "anthropic" + assert d["model"] == "claude-haiku-4-5-20251001" + assert d["env_var"] == "ANTHROPIC_API_KEY" + + def test_from_dict(self) -> None: + data = {"provider": "openai", "model": "gpt-4o"} + config = LLMConfig.from_dict(data) + assert config.provider == "openai" + assert config.model == "gpt-4o" + assert config.env_var is None + + def test_roundtrip(self) -> None: + original = LLMConfig(provider="anthropic", model="claude-sonnet-4-20250514", env_var="ANTHROPIC_API_KEY") + restored = LLMConfig.from_dict(original.to_dict()) + assert restored.provider == original.provider + assert restored.model == original.model + assert restored.env_var == original.env_var + + +class TestLoadSaveConfig: + def test_load_missing_file(self, tmp_path: Path) -> None: + config = load_config(tmp_path / "nonexistent.yaml") + assert config.provider is None + + def test_save_and_load(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + config = LLMConfig(provider="anthropic", model="claude-sonnet-4-20250514") + save_config(config, config_path) + + loaded = load_config(config_path) + assert loaded.provider == "anthropic" + assert loaded.model == "claude-sonnet-4-20250514" + + def test_save_preserves_other_sections(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + config_path.write_text("other_section:\n key: value\n") + + config = LLMConfig(provider="openai") + save_config(config, config_path) + + loaded_text = config_path.read_text() + assert "other_section" in loaded_text + assert "openai" in loaded_text + + def test_save_creates_parent_dirs(self, tmp_path: Path) -> None: + config_path = tmp_path / "nested" / "dir" / "config.yaml" + config = LLMConfig(provider="anthropic") + save_config(config, config_path) + assert config_path.exists() + + def test_load_empty_file(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + config_path.write_text("") + config = load_config(config_path) + assert config.provider is None diff --git a/tests/unit/llm/test_openai.py b/tests/unit/llm/test_openai.py new file mode 100644 index 0000000..c77e980 --- /dev/null +++ b/tests/unit/llm/test_openai.py @@ -0,0 +1,85 @@ +"""Tests for OpenAI provider with mocked HTTP responses.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.llm.openai_provider import OpenAIProvider +from devsync.llm.provider import LLMProviderError + + +def _mock_response(status_code: int, json_data: dict) -> MagicMock: + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = json_data + mock.text = str(json_data) + return mock + + +class TestOpenAIProvider: + def test_name(self) -> None: + provider = OpenAIProvider(api_key="test-key") + assert provider.name == "openai" + + def test_default_model(self) -> None: + provider = OpenAIProvider(api_key="test-key") + assert provider.default_model == "gpt-4o" + + @patch("devsync.llm.openai_provider.httpx.Client") + def test_complete_success(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "choices": [{"message": {"content": "Hello"}}], + "model": "gpt-4o", + "usage": {"prompt_tokens": 8, "completion_tokens": 3, "total_tokens": 11}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = OpenAIProvider(api_key="test-key") + result = provider.complete("test") + + assert result.content == "Hello" + assert result.usage["total_tokens"] == 11 + + @patch("devsync.llm.openai_provider.httpx.Client") + def test_complete_with_system(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "choices": [{"message": {"content": "ok"}}], + "model": "gpt-4o", + "usage": {"prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = OpenAIProvider(api_key="test-key") + provider.complete("test", system="Be helpful") + + body = mock_client.post.call_args[1]["json"] + assert body["messages"][0]["role"] == "system" + assert body["messages"][1]["role"] == "user" + + @patch("devsync.llm.openai_provider.httpx.Client") + def test_complete_api_error(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response(429, {"error": {"message": "Rate limited"}}) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = OpenAIProvider(api_key="test-key") + with pytest.raises(LLMProviderError) as exc_info: + provider.complete("test") + assert exc_info.value.status_code == 429 diff --git a/tests/unit/llm/test_openrouter.py b/tests/unit/llm/test_openrouter.py new file mode 100644 index 0000000..d2e32a7 --- /dev/null +++ b/tests/unit/llm/test_openrouter.py @@ -0,0 +1,70 @@ +"""Tests for OpenRouter provider with mocked HTTP responses.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.llm.openrouter import OpenRouterProvider +from devsync.llm.provider import LLMProviderError + + +def _mock_response(status_code: int, json_data: dict) -> MagicMock: + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = json_data + mock.text = str(json_data) + return mock + + +class TestOpenRouterProvider: + def test_name(self) -> None: + provider = OpenRouterProvider(api_key="test-key") + assert provider.name == "openrouter" + + def test_default_model(self) -> None: + provider = OpenRouterProvider(api_key="test-key") + assert "claude" in provider.default_model + + @patch("devsync.llm.openrouter.httpx.Client") + def test_complete_success(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "choices": [{"message": {"content": "Hi"}}], + "model": "anthropic/claude-sonnet-4-20250514", + "usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = OpenRouterProvider(api_key="test-key") + result = provider.complete("test") + + assert result.content == "Hi" + + @patch("devsync.llm.openrouter.httpx.Client") + def test_sends_referer_header(self, mock_client_cls: MagicMock) -> None: + mock_response = _mock_response( + 200, + { + "choices": [{"message": {"content": "ok"}}], + "model": "test", + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + mock_client = MagicMock() + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=False) + mock_client.post.return_value = mock_response + mock_client_cls.return_value = mock_client + + provider = OpenRouterProvider(api_key="test-key") + provider.complete("test") + + headers = mock_client.post.call_args[1]["headers"] + assert "HTTP-Referer" in headers + assert "X-Title" in headers diff --git a/tests/unit/llm/test_prompts.py b/tests/unit/llm/test_prompts.py new file mode 100644 index 0000000..e0ff349 --- /dev/null +++ b/tests/unit/llm/test_prompts.py @@ -0,0 +1,60 @@ +"""Tests for prompt templates.""" + +from devsync.llm.prompts import ( + ADAPT_PRACTICE_PROMPT, + EXTRACT_MCP_PROMPT, + EXTRACT_PRACTICES_PROMPT, + MERGE_PRACTICES_PROMPT, + format_files_for_extraction, +) + + +class TestPromptTemplates: + def test_extract_practices_has_placeholder(self) -> None: + assert "{files_content}" in EXTRACT_PRACTICES_PROMPT + + def test_extract_practices_renders(self) -> None: + result = EXTRACT_PRACTICES_PROMPT.format(files_content="# Rule 1\nBe safe") + assert "# Rule 1" in result + assert "practices" in result + + def test_extract_mcp_has_placeholder(self) -> None: + assert "{mcp_config}" in EXTRACT_MCP_PROMPT + + def test_adapt_practice_renders(self) -> None: + result = ADAPT_PRACTICE_PROMPT.format( + practice_json='{"name": "test"}', + existing_rules="# Existing\nRule 1", + tool_name="cursor", + ) + assert "cursor" in result + assert "test" in result + + def test_merge_practices_renders(self) -> None: + result = MERGE_PRACTICES_PROMPT.format( + existing_content="# Old rules", + incoming_content="# New rules", + ) + assert "Old rules" in result + assert "New rules" in result + + +class TestFormatFilesForExtraction: + def test_single_file(self) -> None: + files = {"rules/style.md": "# Style Guide\nUse black."} + result = format_files_for_extraction(files) + assert "--- rules/style.md ---" in result + assert "Use black." in result + + def test_multiple_files(self) -> None: + files = { + "rules/style.md": "# Style", + "rules/testing.md": "# Testing", + } + result = format_files_for_extraction(files) + assert "--- rules/style.md ---" in result + assert "--- rules/testing.md ---" in result + + def test_empty_files(self) -> None: + result = format_files_for_extraction({}) + assert result == "" diff --git a/tests/unit/llm/test_provider.py b/tests/unit/llm/test_provider.py new file mode 100644 index 0000000..8a15a15 --- /dev/null +++ b/tests/unit/llm/test_provider.py @@ -0,0 +1,91 @@ +"""Tests for LLM provider abstraction and resolution.""" + +import os +from unittest.mock import patch + +import pytest + +from devsync.llm.provider import LLMProvider, LLMProviderError, LLMResponse, resolve_provider + + +class TestLLMResponse: + def test_create_response(self) -> None: + response = LLMResponse(content="hello", model="test-model") + assert response.content == "hello" + assert response.model == "test-model" + assert response.usage == {} + assert response.raw_response == {} + + def test_create_response_with_usage(self) -> None: + usage = {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} + response = LLMResponse(content="hi", model="m", usage=usage) + assert response.usage["total_tokens"] == 15 + + +class TestLLMProviderError: + def test_error_with_status_code(self) -> None: + err = LLMProviderError("fail", status_code=401) + assert str(err) == "fail" + assert err.status_code == 401 + + def test_error_with_raw_response(self) -> None: + err = LLMProviderError("fail", raw_response={"error": "bad"}) + assert err.raw_response == {"error": "bad"} + + +class TestResolveProvider: + def test_no_env_vars_returns_none(self) -> None: + with patch.dict(os.environ, {}, clear=True): + # Clear any existing keys + for key in ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"): + os.environ.pop(key, None) + result = resolve_provider() + assert result is None + + def test_anthropic_key_resolves_first(self) -> None: + env = { + "ANTHROPIC_API_KEY": "sk-ant-test", + "OPENAI_API_KEY": "sk-test", + } + with patch.dict(os.environ, env, clear=False): + provider = resolve_provider() + assert provider is not None + assert provider.name == "anthropic" + + def test_openai_key_resolves_when_no_anthropic(self) -> None: + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test"}, clear=False): + os.environ.pop("ANTHROPIC_API_KEY", None) + os.environ.pop("OPENROUTER_API_KEY", None) + provider = resolve_provider() + assert provider is not None + assert provider.name == "openai" + + def test_openrouter_key_resolves_last(self) -> None: + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-test"}, clear=False): + os.environ.pop("ANTHROPIC_API_KEY", None) + os.environ.pop("OPENAI_API_KEY", None) + provider = resolve_provider() + assert provider is not None + assert provider.name == "openrouter" + + def test_preferred_provider_used(self) -> None: + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-test", "ANTHROPIC_API_KEY": "sk-ant"}, clear=False): + provider = resolve_provider(preferred_provider="openai") + assert provider is not None + assert provider.name == "openai" + + def test_preferred_provider_no_key_returns_none(self) -> None: + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("OPENAI_API_KEY", None) + result = resolve_provider(preferred_provider="openai") + assert result is None + + def test_invalid_preferred_provider_returns_none(self) -> None: + result = resolve_provider(preferred_provider="nonexistent") + assert result is None + + def test_preferred_model_passed_through(self) -> None: + with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "sk-ant-test"}, clear=False): + provider = resolve_provider(preferred_model="claude-haiku-4-5-20251001") + assert provider is not None + assert provider.default_model == "claude-haiku-4-5-20251001" diff --git a/tests/unit/llm/test_response_models.py b/tests/unit/llm/test_response_models.py new file mode 100644 index 0000000..d2c68e2 --- /dev/null +++ b/tests/unit/llm/test_response_models.py @@ -0,0 +1,130 @@ +"""Tests for LLM response models and parsers.""" + +import json + +import pytest + +from devsync.llm.response_models import ( + AdaptationAction, + AdaptationPlan, + ExtractionResult, + MergeDecision, + parse_adaptation_response, + parse_extraction_response, + parse_merge_response, +) +from devsync.core.practice import MCPDeclaration, PracticeDeclaration + + +class TestExtractionResult: + def test_empty(self) -> None: + result = ExtractionResult() + assert result.practices == [] + assert result.mcp_servers == [] + assert result.ai_powered is True + + def test_to_dict(self) -> None: + result = ExtractionResult( + practices=[PracticeDeclaration(name="test", intent="Test")], + source_files=["rules/test.md"], + ai_powered=True, + ) + d = result.to_dict() + assert len(d["practices"]) == 1 + assert d["source_files"] == ["rules/test.md"] + + +class TestAdaptationPlan: + def test_action_filters(self) -> None: + plan = AdaptationPlan( + actions=[ + AdaptationAction(action="install", practice_name="a", reason="new"), + AdaptationAction(action="merge", practice_name="b", reason="overlap"), + AdaptationAction(action="skip", practice_name="c", reason="exists"), + AdaptationAction(action="install", practice_name="d", reason="new"), + ] + ) + assert len(plan.installs) == 2 + assert len(plan.merges) == 1 + assert len(plan.skips) == 1 + + +class TestParseExtractionResponse: + def test_valid_response(self) -> None: + response = json.dumps( + { + "practices": [ + { + "name": "type-safety", + "intent": "Enforce type hints", + "principles": ["Use type hints everywhere"], + "tags": ["python"], + } + ] + } + ) + practices = parse_extraction_response(response) + assert len(practices) == 1 + assert practices[0].name == "type-safety" + + def test_empty_practices(self) -> None: + response = json.dumps({"practices": []}) + practices = parse_extraction_response(response) + assert practices == [] + + def test_invalid_json(self) -> None: + with pytest.raises(ValueError, match="Invalid JSON"): + parse_extraction_response("not json") + + def test_missing_practices_key(self) -> None: + response = json.dumps({"other": "data"}) + practices = parse_extraction_response(response) + assert practices == [] + + +class TestParseAdaptationResponse: + def test_install_action(self) -> None: + response = json.dumps( + { + "action": "install", + "reason": "No conflicts found", + "file_name": "type-safety.md", + } + ) + action = parse_adaptation_response(response) + assert action.action == "install" + assert action.file_name == "type-safety.md" + + def test_merge_action(self) -> None: + response = json.dumps( + { + "action": "merge", + "reason": "Overlapping rules", + "merged_content": "# Merged\nRule 1\nRule 2", + "file_name": "style.md", + } + ) + action = parse_adaptation_response(response) + assert action.action == "merge" + assert "Merged" in action.content + + def test_invalid_json(self) -> None: + with pytest.raises(ValueError, match="Invalid JSON"): + parse_adaptation_response("{bad") + + +class TestParseMergeResponse: + def test_valid(self) -> None: + response = json.dumps( + { + "merged_content": "# Combined\nAll rules here", + "changes_summary": "Added 3 new rules from incoming", + } + ) + decision = parse_merge_response(response) + assert "Combined" in decision.merged_content + assert "3 new rules" in decision.changes_summary + + def test_invalid_json(self) -> None: + with pytest.raises(ValueError, match="Invalid JSON"): + parse_merge_response("nope") From f92085ec4135355da260e492a69f74ac7ede8491 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:43:25 -0500 Subject: [PATCH 02/14] feat(core): add setup command, practice extractor, and v2 manifest parser (#66, #67, #68) - Setup command for interactive LLM provider configuration - PracticeExtractor with AI and no-AI fallback paths - V2 package manifest parser with v1 backwards compatibility --- devsync/cli/setup.py | 70 ++++++ devsync/core/extractor.py | 168 ++++++++++++++ devsync/core/package_manifest_v2.py | 244 ++++++++++++++++++++ tests/unit/cli/__init__.py | 0 tests/unit/cli/test_setup.py | 84 +++++++ tests/unit/core/test_extractor.py | 124 ++++++++++ tests/unit/core/test_package_manifest_v2.py | 190 +++++++++++++++ 7 files changed, 880 insertions(+) create mode 100644 devsync/cli/setup.py create mode 100644 devsync/core/extractor.py create mode 100644 devsync/core/package_manifest_v2.py create mode 100644 tests/unit/cli/__init__.py create mode 100644 tests/unit/cli/test_setup.py create mode 100644 tests/unit/core/test_extractor.py create mode 100644 tests/unit/core/test_package_manifest_v2.py diff --git a/devsync/cli/setup.py b/devsync/cli/setup.py new file mode 100644 index 0000000..e55bbcc --- /dev/null +++ b/devsync/cli/setup.py @@ -0,0 +1,70 @@ +"""Setup command for configuring LLM provider.""" + +import typer +from rich.console import Console +from rich.prompt import Confirm, Prompt + +from devsync.llm.config import LLMConfig, load_config, save_config +from devsync.llm.provider import resolve_provider + +console = Console() + +_PROVIDER_ENV_VARS = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", +} + +_PROVIDER_DEFAULTS = { + "anthropic": "claude-sonnet-4-20250514", + "openai": "gpt-4o", + "openrouter": "anthropic/claude-sonnet-4-20250514", +} + + +def setup_command() -> int: + """Interactive setup for LLM provider configuration.""" + console.print("\n[bold]DevSync Setup[/bold]") + console.print("Configure your LLM provider for AI-powered features.\n") + + existing = load_config() + if existing.provider: + console.print(f"Current provider: [cyan]{existing.provider}[/cyan]") + if not Confirm.ask("Reconfigure?", default=False): + return 0 + + provider_name = Prompt.ask( + "Select provider", + choices=["anthropic", "openai", "openrouter"], + default="anthropic", + ) + + env_var = _PROVIDER_ENV_VARS[provider_name] + default_model = _PROVIDER_DEFAULTS[provider_name] + + console.print(f"\nSet your API key as an environment variable:") + console.print(f" [cyan]export {env_var}=your-key-here[/cyan]") + console.print(f"\nAdd this to your shell profile (~/.zshrc, ~/.bashrc) for persistence.\n") + + model = Prompt.ask("Model", default=default_model) + + provider = resolve_provider(preferred_provider=provider_name, preferred_model=model) + if provider: + console.print("Validating API key...", end=" ") + if provider.validate_api_key(): + console.print("[green]valid[/green]") + else: + console.print("[red]invalid[/red]") + console.print(f"[yellow]Check that {env_var} is set correctly.[/yellow]") + if not Confirm.ask("Save config anyway?", default=False): + return 1 + else: + console.print(f"[yellow]{env_var} not set in current environment.[/yellow]") + console.print("Config will be saved — set the env var before using AI features.\n") + + config = LLMConfig(provider=provider_name, model=model, env_var=env_var) + save_config(config) + + console.print(f"\n[green]Config saved.[/green] Provider: {provider_name}, Model: {model}") + console.print(f"API key env var: {env_var}") + return 0 diff --git a/devsync/core/extractor.py b/devsync/core/extractor.py new file mode 100644 index 0000000..fbf6c5d --- /dev/null +++ b/devsync/core/extractor.py @@ -0,0 +1,168 @@ +"""AI-powered practice extraction engine.""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from devsync.core.practice import MCPDeclaration, PracticeDeclaration +from devsync.llm.prompts import ( + EXTRACT_MCP_PROMPT, + EXTRACT_PRACTICES_PROMPT, + SYSTEM_PROMPT, + format_files_for_extraction, +) +from devsync.llm.provider import LLMProvider, LLMProviderError +from devsync.llm.response_models import ExtractionResult, parse_extraction_response + +logger = logging.getLogger(__name__) + + +class PracticeExtractor: + """Extracts practice declarations from a project's AI configs. + + Uses LLM intelligence when available, falls back to file-copy mode. + """ + + def __init__(self, llm_provider: Optional[LLMProvider] = None): + self._llm = llm_provider + + def extract(self, project_path: Path) -> ExtractionResult: + """Extract practices from a project directory. + + Args: + project_path: Root directory of the project to analyze. + + Returns: + ExtractionResult with extracted practices and MCP servers. + """ + from devsync.core.component_detector import ComponentDetector + + detector = ComponentDetector(project_path) + detection = detector.detect_all() + + instruction_files = self._read_instruction_files(project_path, detection) + mcp_configs = self._read_mcp_configs(detection) + + if self._llm: + return self._extract_with_ai(instruction_files, mcp_configs) + return self._extract_without_ai(instruction_files, mcp_configs) + + def _read_instruction_files(self, project_path: Path, detection: object) -> dict[str, str]: + """Read instruction file contents from detected components.""" + files: dict[str, str] = {} + for instr in getattr(detection, "instructions", []): + file_path = getattr(instr, "file_path", None) or getattr(instr, "path", None) + if not file_path: + continue + path = Path(file_path) + if not path.is_absolute(): + path = project_path / path + if path.exists() and path.stat().st_size < 100_000: + try: + content = path.read_text(encoding="utf-8") + rel_path = str(path.relative_to(project_path)) + files[rel_path] = content + except (OSError, UnicodeDecodeError): + logger.warning("Could not read %s", path) + return files + + def _read_mcp_configs(self, detection: object) -> list[dict]: + """Read MCP server configurations from detected components.""" + configs = [] + for server in getattr(detection, "mcp_servers", []): + config: dict = {} + for attr in ("name", "command", "args", "env"): + val = getattr(server, attr, None) + if val is not None: + config[attr] = val + if config: + configs.append(config) + return configs + + def _extract_with_ai( + self, files: dict[str, str], mcp_configs: list[dict] + ) -> ExtractionResult: + """Extract practices using LLM intelligence.""" + assert self._llm is not None + + practices: list[PracticeDeclaration] = [] + mcp_servers: list[MCPDeclaration] = [] + + if files: + files_content = format_files_for_extraction(files) + prompt = EXTRACT_PRACTICES_PROMPT.format(files_content=files_content) + try: + response = self._llm.complete(prompt, system=SYSTEM_PROMPT) + practices = parse_extraction_response(response.content) + for i, p in enumerate(practices): + source_files = list(files.keys()) + if i < len(source_files): + practices[i] = PracticeDeclaration( + name=p.name, + intent=p.intent, + principles=p.principles, + enforcement_patterns=p.enforcement_patterns, + examples=p.examples, + tags=p.tags, + source_file=source_files[i] if i < len(source_files) else None, + ) + except (LLMProviderError, ValueError) as e: + logger.warning("AI extraction failed, falling back: %s", e) + practices = self._practices_from_files(files) + + for mcp_config in mcp_configs: + try: + prompt = EXTRACT_MCP_PROMPT.format(mcp_config=json.dumps(mcp_config, indent=2)) + response = self._llm.complete(prompt, system=SYSTEM_PROMPT) + data = json.loads(response.content) + mcp_servers.append(MCPDeclaration.from_dict(data)) + except (LLMProviderError, ValueError, json.JSONDecodeError) as e: + logger.warning("MCP extraction failed for %s: %s", mcp_config.get("name", "unknown"), e) + + return ExtractionResult( + practices=practices, + mcp_servers=mcp_servers, + source_files=list(files.keys()), + ai_powered=True, + ) + + def _extract_without_ai( + self, files: dict[str, str], mcp_configs: list[dict] + ) -> ExtractionResult: + """Extract practices as literal file copies (no AI).""" + practices = self._practices_from_files(files) + + mcp_servers = [] + for config in mcp_configs: + name = config.get("name", "unknown-mcp") + mcp_servers.append( + MCPDeclaration( + name=name, + description=f"MCP server: {name}", + command=config.get("command", ""), + args=config.get("args", []), + ) + ) + + return ExtractionResult( + practices=practices, + mcp_servers=mcp_servers, + source_files=list(files.keys()), + ai_powered=False, + ) + + def _practices_from_files(self, files: dict[str, str]) -> list[PracticeDeclaration]: + """Create literal practice declarations from file contents.""" + practices = [] + for path, content in files.items(): + name = Path(path).stem + practices.append( + PracticeDeclaration( + name=name, + intent=f"Instructions from {path}", + source_file=path, + raw_content=content, + ) + ) + return practices diff --git a/devsync/core/package_manifest_v2.py b/devsync/core/package_manifest_v2.py new file mode 100644 index 0000000..c0a7c43 --- /dev/null +++ b/devsync/core/package_manifest_v2.py @@ -0,0 +1,244 @@ +"""V2 package manifest parser with backwards compatibility.""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +from devsync.core.practice import CredentialSpec, MCPDeclaration, PracticeDeclaration + +logger = logging.getLogger(__name__) + +V2_MANIFEST_FILE = "devsync-package.yaml" +V1_MANIFEST_FILE = "ai-config-kit-package.yaml" + + +@dataclass +class ComponentRef: + """Reference to a file-based component (v1 compatibility). + + Attributes: + name: Component identifier. + file: Relative file path within the package. + description: Human-readable description. + tags: Categorization tags. + hook_type: For hooks, the type (pre-commit, etc.). + command_type: For commands, the type (shell, etc.). + """ + + name: str + file: str + description: str = "" + tags: list[str] = field(default_factory=list) + hook_type: Optional[str] = None + command_type: Optional[str] = None + + def to_dict(self) -> dict: + result: dict = {"name": self.name, "file": self.file} + if self.description: + result["description"] = self.description + if self.tags: + result["tags"] = self.tags + if self.hook_type: + result["hook_type"] = self.hook_type + if self.command_type: + result["command_type"] = self.command_type + return result + + @classmethod + def from_dict(cls, data: dict) -> "ComponentRef": + return cls( + name=data["name"], + file=data["file"], + description=data.get("description", ""), + tags=data.get("tags", []), + hook_type=data.get("hook_type"), + command_type=data.get("command_type"), + ) + + +@dataclass +class PackageManifestV2: + """Unified package manifest supporting both v1 and v2 formats. + + Attributes: + format_version: '1.0' for v1, '2.0' for v2. + name: Package name. + version: Package version. + description: Package description. + author: Package author. + license: License identifier. + namespace: Source namespace (e.g., 'org/repo'). + practices: AI-extracted practice declarations (v2 only). + mcp_servers: MCP server declarations. + components: File-based component references (v1 compatibility). + """ + + format_version: str = "2.0" + name: str = "" + version: str = "1.0.0" + description: str = "" + author: str = "" + license: str = "" + namespace: str = "" + practices: list[PracticeDeclaration] = field(default_factory=list) + mcp_servers: list[MCPDeclaration] = field(default_factory=list) + components: dict[str, list[ComponentRef]] = field(default_factory=dict) + + @property + def is_v2(self) -> bool: + return self.format_version.startswith("2") + + @property + def has_practices(self) -> bool: + return len(self.practices) > 0 + + @property + def has_components(self) -> bool: + return any(len(refs) > 0 for refs in self.components.values()) + + def to_dict(self) -> dict: + result: dict = { + "format_version": self.format_version, + "name": self.name, + "version": self.version, + "description": self.description, + } + if self.author: + result["author"] = self.author + if self.license: + result["license"] = self.license + if self.namespace: + result["namespace"] = self.namespace + if self.practices: + result["practices"] = [p.to_dict() for p in self.practices] + if self.mcp_servers: + result["mcp_servers"] = [m.to_dict() for m in self.mcp_servers] + if self.has_components: + result["components"] = { + key: [c.to_dict() for c in refs] for key, refs in self.components.items() if refs + } + return result + + def to_yaml(self) -> str: + return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False) + + +def detect_manifest_format(package_path: Path) -> Optional[str]: + """Detect whether a package uses v1 or v2 manifest format. + + Args: + package_path: Root directory of the package. + + Returns: + 'v2' if devsync-package.yaml exists, 'v1' if ai-config-kit-package.yaml exists, + None if neither found. + """ + if (package_path / V2_MANIFEST_FILE).exists(): + return "v2" + if (package_path / V1_MANIFEST_FILE).exists(): + return "v1" + return None + + +def parse_manifest(package_path: Path) -> PackageManifestV2: + """Parse a package manifest (auto-detects v1 vs v2). + + Args: + package_path: Root directory of the package. + + Returns: + PackageManifestV2 with parsed content. + + Raises: + FileNotFoundError: If no manifest file found. + ValueError: If manifest is malformed. + """ + fmt = detect_manifest_format(package_path) + if fmt == "v2": + return _parse_v2(package_path / V2_MANIFEST_FILE) + if fmt == "v1": + return _parse_v1(package_path / V1_MANIFEST_FILE) + raise FileNotFoundError(f"No manifest found in {package_path} (expected {V2_MANIFEST_FILE} or {V1_MANIFEST_FILE})") + + +def _parse_v2(manifest_path: Path) -> PackageManifestV2: + """Parse a v2 devsync-package.yaml manifest.""" + with open(manifest_path) as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + raise ValueError(f"Invalid manifest: expected dict, got {type(data).__name__}") + + practices = [PracticeDeclaration.from_dict(p) for p in data.get("practices", [])] + + mcp_servers = [] + for m in data.get("mcp_servers", []): + mcp_servers.append(MCPDeclaration.from_dict(m)) + + components = _parse_components(data.get("components", {})) + + return PackageManifestV2( + format_version=str(data.get("format_version", "2.0")), + name=data.get("name", ""), + version=str(data.get("version", "1.0.0")), + description=data.get("description", ""), + author=data.get("author", ""), + license=data.get("license", ""), + namespace=data.get("namespace", ""), + practices=practices, + mcp_servers=mcp_servers, + components=components, + ) + + +def _parse_v1(manifest_path: Path) -> PackageManifestV2: + """Parse a v1 ai-config-kit-package.yaml manifest into the v2 structure.""" + with open(manifest_path) as f: + data = yaml.safe_load(f) + + if not isinstance(data, dict): + raise ValueError(f"Invalid manifest: expected dict, got {type(data).__name__}") + + components = _parse_components(data.get("components", {})) + + mcp_servers = [] + for m in data.get("components", {}).get("mcp_servers", []): + creds = [ + CredentialSpec.from_dict(c) for c in m.get("credentials", []) + ] + mcp_servers.append( + MCPDeclaration( + name=m["name"], + description=m.get("description", ""), + command=m.get("command", ""), + args=m.get("args", []), + credentials=creds, + ) + ) + + return PackageManifestV2( + format_version="1.0", + name=data.get("name", ""), + version=str(data.get("version", "1.0.0")), + description=data.get("description", ""), + author=data.get("author", ""), + license=data.get("license", ""), + namespace=data.get("namespace", ""), + practices=[], + mcp_servers=mcp_servers, + components=components, + ) + + +def _parse_components(components_data: dict) -> dict[str, list[ComponentRef]]: + """Parse the components section into ComponentRef lists.""" + result: dict[str, list[ComponentRef]] = {} + for component_type, items in components_data.items(): + if component_type == "mcp_servers": + continue + if isinstance(items, list): + result[component_type] = [ComponentRef.from_dict(item) for item in items] + return result diff --git a/tests/unit/cli/__init__.py b/tests/unit/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/cli/test_setup.py b/tests/unit/cli/test_setup.py new file mode 100644 index 0000000..4b5e7bd --- /dev/null +++ b/tests/unit/cli/test_setup.py @@ -0,0 +1,84 @@ +"""Tests for the setup command.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.cli.setup import setup_command +from devsync.llm.config import LLMConfig, load_config + + +class TestSetupCommand: + @patch("devsync.cli.setup.Confirm.ask", return_value=False) + @patch("devsync.cli.setup.load_config") + def test_existing_config_no_reconfigure(self, mock_load: MagicMock, mock_confirm: MagicMock) -> None: + mock_load.return_value = LLMConfig(provider="anthropic") + result = setup_command() + assert result == 0 + + @patch("devsync.cli.setup.save_config") + @patch("devsync.cli.setup.resolve_provider", return_value=None) + @patch("devsync.cli.setup.Prompt.ask", side_effect=["anthropic", "claude-sonnet-4-20250514"]) + @patch("devsync.cli.setup.load_config", return_value=LLMConfig()) + def test_new_config_no_api_key( + self, + mock_load: MagicMock, + mock_prompt: MagicMock, + mock_resolve: MagicMock, + mock_save: MagicMock, + ) -> None: + result = setup_command() + assert result == 0 + mock_save.assert_called_once() + saved_config = mock_save.call_args[0][0] + assert saved_config.provider == "anthropic" + assert saved_config.env_var == "ANTHROPIC_API_KEY" + + @patch("devsync.cli.setup.save_config") + @patch("devsync.cli.setup.Prompt.ask", side_effect=["openai", "gpt-4o"]) + @patch("devsync.cli.setup.load_config", return_value=LLMConfig()) + def test_openai_provider_selection( + self, + mock_load: MagicMock, + mock_prompt: MagicMock, + mock_save: MagicMock, + ) -> None: + mock_provider = MagicMock() + mock_provider.validate_api_key.return_value = True + with patch("devsync.cli.setup.resolve_provider", return_value=mock_provider): + result = setup_command() + assert result == 0 + saved_config = mock_save.call_args[0][0] + assert saved_config.provider == "openai" + assert saved_config.env_var == "OPENAI_API_KEY" + + @patch("devsync.cli.setup.save_config") + @patch("devsync.cli.setup.Prompt.ask", side_effect=["anthropic", "claude-sonnet-4-20250514"]) + @patch("devsync.cli.setup.load_config", return_value=LLMConfig()) + def test_valid_api_key( + self, + mock_load: MagicMock, + mock_prompt: MagicMock, + mock_save: MagicMock, + ) -> None: + mock_provider = MagicMock() + mock_provider.validate_api_key.return_value = True + with patch("devsync.cli.setup.resolve_provider", return_value=mock_provider): + result = setup_command() + assert result == 0 + + @patch("devsync.cli.setup.Confirm.ask", return_value=False) + @patch("devsync.cli.setup.Prompt.ask", side_effect=["anthropic", "claude-sonnet-4-20250514"]) + @patch("devsync.cli.setup.load_config", return_value=LLMConfig()) + def test_invalid_api_key_decline_save( + self, + mock_load: MagicMock, + mock_prompt: MagicMock, + mock_confirm: MagicMock, + ) -> None: + mock_provider = MagicMock() + mock_provider.validate_api_key.return_value = False + with patch("devsync.cli.setup.resolve_provider", return_value=mock_provider): + result = setup_command() + assert result == 1 diff --git a/tests/unit/core/test_extractor.py b/tests/unit/core/test_extractor.py new file mode 100644 index 0000000..0f1282b --- /dev/null +++ b/tests/unit/core/test_extractor.py @@ -0,0 +1,124 @@ +"""Tests for PracticeExtractor.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.core.extractor import PracticeExtractor +from devsync.llm.provider import LLMProviderError, LLMResponse + + +def _make_detection_result(instructions: list | None = None, mcp_servers: list | None = None) -> MagicMock: + result = MagicMock() + result.instructions = instructions or [] + result.mcp_servers = mcp_servers or [] + return result + + +class TestPracticeExtractorNoAI: + def test_extract_empty_project(self, tmp_path: Path) -> None: + with patch("devsync.core.component_detector.ComponentDetector") as mock_cls: + mock_cls.return_value.detect_all.return_value = _make_detection_result() + extractor = PracticeExtractor(llm_provider=None) + result = extractor.extract(tmp_path) + + assert result.ai_powered is False + assert result.practices == [] + assert result.mcp_servers == [] + + def test_extract_with_instruction_files(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".cursor" / "rules" + rules_dir.mkdir(parents=True) + rule_file = rules_dir / "style.md" + rule_file.write_text("# Style Guide\nUse black.") + + mock_instr = MagicMock() + mock_instr.file_path = str(rule_file) + mock_instr.path = None + + with patch("devsync.core.component_detector.ComponentDetector") as mock_cls: + mock_cls.return_value.detect_all.return_value = _make_detection_result(instructions=[mock_instr]) + extractor = PracticeExtractor(llm_provider=None) + result = extractor.extract(tmp_path) + + assert result.ai_powered is False + assert len(result.practices) == 1 + assert result.practices[0].name == "style" + assert "Use black" in (result.practices[0].raw_content or "") + + def test_extract_with_mcp_servers(self, tmp_path: Path) -> None: + mock_server = MagicMock() + mock_server.name = "github" + mock_server.command = "npx" + mock_server.args = ["-y", "server"] + mock_server.env = None + + with patch("devsync.core.component_detector.ComponentDetector") as mock_cls: + mock_cls.return_value.detect_all.return_value = _make_detection_result(mcp_servers=[mock_server]) + extractor = PracticeExtractor(llm_provider=None) + result = extractor.extract(tmp_path) + + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].name == "github" + assert result.mcp_servers[0].command == "npx" + + +class TestPracticeExtractorWithAI: + def test_extract_with_ai(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + rule_file = rules_dir / "types.md" + rule_file.write_text("# Type Safety\nAll functions need type hints.") + + mock_instr = MagicMock() + mock_instr.file_path = str(rule_file) + mock_instr.path = None + + llm_response = LLMResponse( + content=json.dumps( + { + "practices": [ + { + "name": "type-safety", + "intent": "Enforce strict type hints", + "principles": ["All functions must have type hints"], + "tags": ["python", "typing"], + } + ] + } + ), + model="test", + ) + mock_provider = MagicMock() + mock_provider.complete.return_value = llm_response + + with patch("devsync.core.component_detector.ComponentDetector") as mock_cls: + mock_cls.return_value.detect_all.return_value = _make_detection_result(instructions=[mock_instr]) + extractor = PracticeExtractor(llm_provider=mock_provider) + result = extractor.extract(tmp_path) + + assert result.ai_powered is True + assert len(result.practices) == 1 + assert result.practices[0].name == "type-safety" + + def test_ai_fallback_on_error(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".cursor" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "test.md").write_text("# Test") + + mock_instr = MagicMock() + mock_instr.file_path = str(rules_dir / "test.md") + mock_instr.path = None + + mock_provider = MagicMock() + mock_provider.complete.side_effect = LLMProviderError("API error") + + with patch("devsync.core.component_detector.ComponentDetector") as mock_cls: + mock_cls.return_value.detect_all.return_value = _make_detection_result(instructions=[mock_instr]) + extractor = PracticeExtractor(llm_provider=mock_provider) + result = extractor.extract(tmp_path) + + assert len(result.practices) == 1 + assert result.practices[0].raw_content == "# Test" diff --git a/tests/unit/core/test_package_manifest_v2.py b/tests/unit/core/test_package_manifest_v2.py new file mode 100644 index 0000000..c8b3a53 --- /dev/null +++ b/tests/unit/core/test_package_manifest_v2.py @@ -0,0 +1,190 @@ +"""Tests for v2 package manifest parser.""" + +from pathlib import Path + +import pytest +import yaml + +from devsync.core.package_manifest_v2 import ( + ComponentRef, + PackageManifestV2, + detect_manifest_format, + parse_manifest, +) + + +class TestComponentRef: + def test_create(self) -> None: + ref = ComponentRef(name="test", file="instructions/test.md") + assert ref.name == "test" + assert ref.description == "" + + def test_roundtrip(self) -> None: + ref = ComponentRef(name="hook", file="hooks/pre.sh", hook_type="pre-commit", tags=["git"]) + restored = ComponentRef.from_dict(ref.to_dict()) + assert restored.name == ref.name + assert restored.hook_type == ref.hook_type + + +class TestPackageManifestV2: + def test_empty_manifest(self) -> None: + m = PackageManifestV2() + assert m.is_v2 is True + assert m.has_practices is False + assert m.has_components is False + + def test_v1_format_version(self) -> None: + m = PackageManifestV2(format_version="1.0") + assert m.is_v2 is False + + def test_to_dict(self) -> None: + from devsync.core.practice import PracticeDeclaration + + m = PackageManifestV2( + name="test-pkg", + version="1.0.0", + description="Test package", + practices=[PracticeDeclaration(name="style", intent="Code style")], + ) + d = m.to_dict() + assert d["name"] == "test-pkg" + assert len(d["practices"]) == 1 + + def test_to_yaml(self) -> None: + m = PackageManifestV2(name="pkg", version="1.0.0", description="Test") + yaml_str = m.to_yaml() + loaded = yaml.safe_load(yaml_str) + assert loaded["name"] == "pkg" + + +class TestDetectManifestFormat: + def test_v2_format(self, tmp_path: Path) -> None: + (tmp_path / "devsync-package.yaml").write_text("name: test") + assert detect_manifest_format(tmp_path) == "v2" + + def test_v1_format(self, tmp_path: Path) -> None: + (tmp_path / "ai-config-kit-package.yaml").write_text("name: test") + assert detect_manifest_format(tmp_path) == "v1" + + def test_v2_takes_priority(self, tmp_path: Path) -> None: + (tmp_path / "devsync-package.yaml").write_text("name: test") + (tmp_path / "ai-config-kit-package.yaml").write_text("name: test") + assert detect_manifest_format(tmp_path) == "v2" + + def test_no_manifest(self, tmp_path: Path) -> None: + assert detect_manifest_format(tmp_path) is None + + +class TestParseManifest: + def test_parse_v2_manifest(self, tmp_path: Path) -> None: + manifest = { + "format_version": "2.0", + "name": "team-standards", + "version": "1.0.0", + "description": "Python standards", + "author": "Team", + "practices": [ + { + "name": "type-safety", + "intent": "Enforce type hints", + "principles": ["Use type hints"], + "tags": ["python"], + } + ], + "mcp_servers": [ + { + "name": "github-mcp", + "description": "GitHub API", + "command": "npx", + "args": ["-y", "server"], + "credentials": [{"name": "GITHUB_TOKEN", "description": "PAT"}], + } + ], + "components": { + "instructions": [ + {"name": "style", "file": "instructions/style.md", "tags": ["python"]} + ] + }, + } + (tmp_path / "devsync-package.yaml").write_text(yaml.dump(manifest)) + + result = parse_manifest(tmp_path) + assert result.is_v2 + assert result.name == "team-standards" + assert len(result.practices) == 1 + assert result.practices[0].name == "type-safety" + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].name == "github-mcp" + assert len(result.mcp_servers[0].credentials) == 1 + assert len(result.components.get("instructions", [])) == 1 + + def test_parse_v1_manifest(self, tmp_path: Path) -> None: + manifest = { + "name": "old-package", + "version": "0.5.0", + "description": "Legacy package", + "author": "Dev", + "license": "MIT", + "namespace": "org/repo", + "components": { + "instructions": [ + {"name": "rules", "file": "instructions/rules.md", "description": "Rules"} + ], + "mcp_servers": [ + { + "name": "db", + "description": "Database", + "command": "python", + "args": ["server.py"], + "credentials": [{"name": "DB_PASS", "description": "DB password"}], + } + ], + }, + } + (tmp_path / "ai-config-kit-package.yaml").write_text(yaml.dump(manifest)) + + result = parse_manifest(tmp_path) + assert not result.is_v2 + assert result.name == "old-package" + assert result.practices == [] + assert len(result.mcp_servers) == 1 + assert result.mcp_servers[0].name == "db" + assert len(result.components.get("instructions", [])) == 1 + + def test_parse_no_manifest_raises(self, tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + parse_manifest(tmp_path) + + def test_parse_invalid_yaml_raises(self, tmp_path: Path) -> None: + (tmp_path / "devsync-package.yaml").write_text("- not: a: dict: [invalid") + with pytest.raises(Exception): + parse_manifest(tmp_path) + + def test_parse_hybrid_v2_package(self, tmp_path: Path) -> None: + """V2 package with both practices and v1-style components.""" + manifest = { + "format_version": "2.0", + "name": "hybrid", + "version": "1.0.0", + "description": "Hybrid package", + "practices": [ + {"name": "testing", "intent": "Ensure test coverage", "tags": ["testing"]} + ], + "components": { + "instructions": [ + {"name": "testing-rules", "file": "instructions/testing.md"} + ], + "hooks": [ + {"name": "pre-commit", "file": "hooks/pre-commit.sh", "hook_type": "pre-commit"} + ], + }, + } + (tmp_path / "devsync-package.yaml").write_text(yaml.dump(manifest)) + + result = parse_manifest(tmp_path) + assert result.is_v2 + assert result.has_practices + assert result.has_components + assert len(result.practices) == 1 + assert len(result.components["instructions"]) == 1 + assert len(result.components["hooks"]) == 1 From 5bf534e6ae6ab39590af42c21a9cd6add55400a5 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:46:33 -0500 Subject: [PATCH 03/14] feat(core): add extract CLI, install v2, practice adapter, and MCP cred prompting (#69, #70, #71, #72) - Extract command produces shareable v2 packages from project configs - Install v2 with AI-powered adaptation and v1 backwards compat - PracticeAdapter with LLM-powered semantic merge - MCP credential prompting with .env file output --- devsync/cli/extract.py | 112 ++++++++ devsync/cli/install_v2.py | 239 ++++++++++++++++++ devsync/core/adapter.py | 185 ++++++++++++++ devsync/core/mcp_credential_prompter.py | 126 +++++++++ tests/unit/cli/test_extract.py | 61 +++++ tests/unit/cli/test_install_v2.py | 79 ++++++ tests/unit/core/test_adapter.py | 141 +++++++++++ .../unit/core/test_mcp_credential_prompter.py | 76 ++++++ 8 files changed, 1019 insertions(+) create mode 100644 devsync/cli/extract.py create mode 100644 devsync/cli/install_v2.py create mode 100644 devsync/core/adapter.py create mode 100644 devsync/core/mcp_credential_prompter.py create mode 100644 tests/unit/cli/test_extract.py create mode 100644 tests/unit/cli/test_install_v2.py create mode 100644 tests/unit/core/test_adapter.py create mode 100644 tests/unit/core/test_mcp_credential_prompter.py diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py new file mode 100644 index 0000000..927c072 --- /dev/null +++ b/devsync/cli/extract.py @@ -0,0 +1,112 @@ +"""Extract command — reads project configs and produces a shareable package.""" + +import shutil +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn + +from devsync.core.extractor import PracticeExtractor +from devsync.core.package_manifest_v2 import PackageManifestV2 +from devsync.llm.config import load_config +from devsync.llm.provider import resolve_provider + +console = Console() + + +def extract_command( + output: Optional[str] = None, + name: Optional[str] = None, + no_ai: bool = False, + project_dir: Optional[str] = None, +) -> int: + """Extract practices from the current project into a shareable package. + + Args: + output: Output directory for the package. Defaults to './devsync-package/'. + name: Package name. Defaults to project directory name. + no_ai: Force file-copy mode (no LLM calls). + project_dir: Project directory to extract from. Defaults to cwd. + + Returns: + Exit code (0 = success). + """ + 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 + + package_name = name or project_path.name + output_path = Path(output) if output else project_path / "devsync-package" + + llm = None + if not no_ai: + config = load_config() + llm = resolve_provider( + preferred_provider=config.provider, + preferred_model=config.model, + ) + if not llm: + console.print("[yellow]No LLM API key found. Using file-copy mode.[/yellow]") + console.print("Run [cyan]devsync setup[/cyan] to configure AI features.\n") + + extractor = PracticeExtractor(llm_provider=llm) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("Scanning project...", total=None) + result = extractor.extract(project_path) + progress.update(task, description="Building package...") + + output_path.mkdir(parents=True, exist_ok=True) + + manifest = PackageManifestV2( + format_version="2.0", + name=package_name, + version="1.0.0", + description=f"Extracted from {project_path.name}", + practices=result.practices, + mcp_servers=result.mcp_servers, + ) + + if not result.ai_powered: + _copy_source_files(project_path, output_path, result.source_files) + components: dict = {} + if result.source_files: + from devsync.core.package_manifest_v2 import ComponentRef + + components["instructions"] = [ + ComponentRef( + name=Path(f).stem, + file=f"instructions/{Path(f).name}", + ) + for f in result.source_files + ] + manifest.components = components + + manifest_path = output_path / "devsync-package.yaml" + manifest_path.write_text(manifest.to_yaml()) + + 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" 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]") + return 0 + + +def _copy_source_files(project_path: Path, output_path: Path, source_files: list[str]) -> None: + """Copy source instruction files to the output package directory.""" + instructions_dir = output_path / "instructions" + instructions_dir.mkdir(parents=True, exist_ok=True) + for rel_path in source_files: + src = project_path / rel_path + if src.exists(): + dest = instructions_dir / Path(rel_path).name + shutil.copy2(str(src), str(dest)) diff --git a/devsync/cli/install_v2.py b/devsync/cli/install_v2.py new file mode 100644 index 0000000..bc4d94e --- /dev/null +++ b/devsync/cli/install_v2.py @@ -0,0 +1,239 @@ +"""V2 install command — AI-powered package installation.""" + +import tempfile +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.prompt import Confirm +from rich.table import Table + +from devsync.core.adapter import PracticeAdapter +from devsync.core.mcp_credential_prompter import build_mcp_config, prompt_mcp_credentials +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 +from devsync.llm.response_models import AdaptationPlan +from devsync.utils.project import find_project_root + +console = Console() + + +def install_v2_command( + source: str, + tool: Optional[list[str]] = None, + no_ai: bool = False, + conflict: str = "prompt", + project_dir: Optional[str] = None, +) -> int: + """Install a package into the current project. + + Accepts Git URLs, local paths, or package directories. + + Args: + source: Package source (Git URL, local path, or directory). + tool: Target AI tool(s). Auto-detects if not specified. + no_ai: Disable AI-powered adaptation. + conflict: Conflict strategy ('prompt', 'skip', 'overwrite', 'rename'). + project_dir: Target project directory. Defaults to cwd. + + Returns: + Exit code (0 = success). + """ + project_path = Path(project_dir) if project_dir else Path.cwd() + project_root = find_project_root(project_path) + if not project_root: + project_root = project_path + + package_path = _resolve_source(source) + if not package_path: + console.print(f"[red]Could not resolve source: {source}[/red]") + return 1 + + fmt = detect_manifest_format(package_path) + if not fmt: + console.print(f"[red]No manifest found in {package_path}[/red]") + console.print("Expected: devsync-package.yaml or ai-config-kit-package.yaml") + return 1 + + manifest = parse_manifest(package_path) + target_tools = _resolve_tools(tool) + + console.print(f"\n[bold]Installing: {manifest.name} v{manifest.version}[/bold]") + console.print(f" {manifest.description}") + console.print(f" Format: {'v2 (AI-native)' if manifest.is_v2 else 'v1 (file-copy)'}") + console.print(f" Tools: {', '.join(target_tools)}") + + if manifest.is_v2 and manifest.has_practices and not no_ai: + return _install_v2_ai(manifest, project_root, target_tools) + return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict) + + +def _resolve_source(source: str) -> Optional[Path]: + """Resolve a source string to a local package directory.""" + source_path = Path(source).expanduser() + if source_path.is_dir(): + return source_path + + if source.startswith(("http://", "https://", "git@", "github.com")): + return _clone_source(source) + + if source_path.exists(): + return source_path + + return None + + +def _clone_source(url: str) -> Optional[Path]: + """Clone a Git repository to a temp directory.""" + try: + from devsync.core.git_operations import clone_repository + + tmp_dir = Path(tempfile.mkdtemp(prefix="devsync-")) + clone_repository(url, tmp_dir) + return tmp_dir + except Exception as e: + console.print(f"[red]Failed to clone {url}: {e}[/red]") + return None + + +def _resolve_tools(tool_names: Optional[list[str]]) -> list[str]: + """Resolve target tools (auto-detect if not specified).""" + if tool_names: + return tool_names + + from devsync.ai_tools.detector import get_detector + + detected = get_detector().detect_installed_tools() + if detected: + return [t.tool_type.value for t in detected] + + return ["claude"] + + +def _install_v2_ai( + manifest: PackageManifestV2, + project_root: Path, + target_tools: list[str], +) -> int: + """Install using AI-powered adaptation.""" + config = load_config() + llm = resolve_provider(preferred_provider=config.provider, preferred_model=config.model) + + adapter = PracticeAdapter(llm_provider=llm) + plan = adapter.adapt(manifest.practices, project_root, target_tools) + + _display_plan(plan) + + if not Confirm.ask("\nProceed with installation?", default=True): + console.print("[yellow]Installation cancelled.[/yellow]") + return 0 + + _execute_plan(plan, project_root, target_tools) + + if manifest.mcp_servers: + _install_mcp_servers(manifest, project_root) + + console.print(f"\n[green]Installed {manifest.name} successfully.[/green]") + return 0 + + +def _install_v2_fallback( + manifest: PackageManifestV2, + package_path: Path, + project_root: Path, + target_tools: list[str], + conflict: str, +) -> int: + """Install using file-copy mode (v1 compat or --no-ai).""" + from devsync.core.models import ConflictResolution + + conflict_strategy = ConflictResolution(conflict) if conflict != "prompt" else ConflictResolution.SKIP + + installed_count = 0 + + for component_type, refs in manifest.components.items(): + if component_type != "instructions": + continue + for ref in refs: + src_file = package_path / ref.file + if not src_file.exists(): + console.print(f" [yellow]Missing: {ref.file}[/yellow]") + continue + + content = src_file.read_text(encoding="utf-8") + for tool_name in target_tools: + dest = _get_tool_instruction_path(tool_name, project_root, ref.name) + if dest and not dest.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content, encoding="utf-8") + installed_count += 1 + console.print(f" Installed: {ref.name} → {dest.relative_to(project_root)}") + + if manifest.mcp_servers: + _install_mcp_servers(manifest, project_root) + + console.print(f"\n[green]Installed {installed_count} instructions.[/green]") + return 0 + + +def _display_plan(plan: AdaptationPlan) -> None: + """Display the adaptation plan for user review.""" + table = Table(title="Adaptation Plan") + table.add_column("Practice", style="cyan") + table.add_column("Action", style="bold") + table.add_column("Reason") + + for action in plan.actions: + style = {"install": "green", "merge": "yellow", "skip": "dim"}.get(action.action, "") + table.add_row(action.practice_name, f"[{style}]{action.action}[/{style}]", action.reason) + + console.print(table) + console.print(f"\n Install: {len(plan.installs)} | Merge: {len(plan.merges)} | Skip: {len(plan.skips)}") + + +def _execute_plan(plan: AdaptationPlan, project_root: Path, target_tools: list[str]) -> None: + """Execute the adaptation plan — write files to tool-specific directories.""" + for action in plan.actions: + if action.action == "skip": + continue + for tool_name in target_tools: + dest = _get_tool_instruction_path(tool_name, project_root, action.practice_name) + if dest: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(action.content, encoding="utf-8") + console.print(f" Installed: {action.practice_name} → {dest.relative_to(project_root)}") + + +def _get_tool_instruction_path(tool_name: str, project_root: Path, instruction_name: str) -> Optional[Path]: + """Get the file path for an instruction in a specific tool.""" + tool_paths: dict[str, tuple[str, str]] = { + "claude": (".claude/rules", ".md"), + "cursor": (".cursor/rules", ".mdc"), + "windsurf": (".windsurf/rules", ".md"), + "copilot": (".github/instructions", ".md"), + "kiro": (".kiro/steering", ".md"), + "cline": (".clinerules", ".md"), + "roo": (".roo/rules", ".md"), + } + if tool_name not in tool_paths: + return None + dir_name, ext = tool_paths[tool_name] + return project_root / dir_name / f"{instruction_name}{ext}" + + +def _install_mcp_servers(manifest: PackageManifestV2, project_root: Path) -> None: + """Install MCP server configurations with credential prompting.""" + servers_with_creds = [s for s in manifest.mcp_servers if s.credentials] + if servers_with_creds: + env_path = project_root / ".devsync" / ".env" + credentials = prompt_mcp_credentials(servers_with_creds, env_path=env_path) + + for server in manifest.mcp_servers: + server_creds = credentials.get(server.name, {}) + config = build_mcp_config(server, server_creds) + console.print(f" MCP: {server.name} configured") + else: + for server in manifest.mcp_servers: + console.print(f" MCP: {server.name} (no credentials needed)") diff --git a/devsync/core/adapter.py b/devsync/core/adapter.py new file mode 100644 index 0000000..b64510e --- /dev/null +++ b/devsync/core/adapter.py @@ -0,0 +1,185 @@ +"""AI-powered practice adaptation engine.""" + +import json +import logging +from pathlib import Path +from typing import Optional + +from devsync.core.practice import PracticeDeclaration +from devsync.llm.prompts import ADAPT_PRACTICE_PROMPT, MERGE_PRACTICES_PROMPT, SYSTEM_PROMPT +from devsync.llm.provider import LLMProvider, LLMProviderError +from devsync.llm.response_models import ( + AdaptationAction, + AdaptationPlan, + MergeDecision, + parse_adaptation_response, + parse_merge_response, +) + +logger = logging.getLogger(__name__) + + +class PracticeAdapter: + """Adapts incoming practices to a target project's existing setup. + + Uses LLM intelligence for semantic merging when available, + falls back to standard conflict resolution otherwise. + """ + + def __init__(self, llm_provider: Optional[LLMProvider] = None): + self._llm = llm_provider + + def adapt( + self, + practices: list[PracticeDeclaration], + project_path: Path, + target_tools: list[str], + ) -> AdaptationPlan: + """Create an adaptation plan for installing practices. + + Args: + practices: Practices to install. + project_path: Target project root. + target_tools: AI tools to install to. + + Returns: + AdaptationPlan for user review before execution. + """ + existing_rules = self._detect_existing_rules(project_path) + + if self._llm and existing_rules: + return self._adapt_with_ai(practices, existing_rules, target_tools) + return self._adapt_without_ai(practices, existing_rules, target_tools) + + def _detect_existing_rules(self, project_path: Path) -> dict[str, str]: + """Detect existing instruction files in the target project.""" + rules: dict[str, str] = {} + rule_dirs = [ + ".cursor/rules", + ".claude/rules", + ".windsurf/rules", + ".github/instructions", + ".kiro/steering", + ".clinerules", + ".roo/rules", + ] + for rule_dir in rule_dirs: + dir_path = project_path / rule_dir + if dir_path.is_dir(): + for f in dir_path.iterdir(): + if f.is_file() and f.suffix in (".md", ".mdc"): + try: + content = f.read_text(encoding="utf-8") + rel_path = str(f.relative_to(project_path)) + rules[rel_path] = content + except (OSError, UnicodeDecodeError): + pass + return rules + + def _adapt_with_ai( + self, + practices: list[PracticeDeclaration], + existing_rules: dict[str, str], + target_tools: list[str], + ) -> AdaptationPlan: + """Use LLM to create semantic adaptation plan.""" + assert self._llm is not None + actions: list[AdaptationAction] = [] + existing_summary = "\n".join( + f"--- {path} ---\n{content[:500]}" for path, content in existing_rules.items() + ) + + for practice in practices: + try: + prompt = ADAPT_PRACTICE_PROMPT.format( + practice_json=json.dumps(practice.to_dict(), indent=2), + existing_rules=existing_summary, + tool_name=", ".join(target_tools), + ) + response = self._llm.complete(prompt, system=SYSTEM_PROMPT) + action = parse_adaptation_response(response.content) + action.practice_name = practice.name + + if action.action == "install" and not action.content: + action.content = self._render_practice(practice) + if not action.file_name: + action.file_name = f"{practice.name}.md" + + actions.append(action) + except (LLMProviderError, ValueError) as e: + logger.warning("AI adaptation failed for %s: %s", practice.name, e) + actions.append( + AdaptationAction( + action="install", + practice_name=practice.name, + reason="AI adaptation failed, installing as-is", + file_name=f"{practice.name}.md", + content=self._render_practice(practice), + ) + ) + + return AdaptationPlan(actions=actions, target_tools=target_tools, ai_powered=True) + + def _adapt_without_ai( + self, + practices: list[PracticeDeclaration], + existing_rules: dict[str, str], + target_tools: list[str], + ) -> AdaptationPlan: + """Create adaptation plan without AI (standard conflict resolution).""" + actions: list[AdaptationAction] = [] + existing_names = {Path(p).stem.lower() for p in existing_rules} + + for practice in practices: + file_name = f"{practice.name}.md" + if practice.name.lower() in existing_names: + actions.append( + AdaptationAction( + action="skip", + practice_name=practice.name, + reason=f"File with name '{practice.name}' already exists", + file_name=file_name, + ) + ) + else: + actions.append( + AdaptationAction( + action="install", + practice_name=practice.name, + reason="No conflict detected", + file_name=file_name, + content=self._render_practice(practice), + ) + ) + + return AdaptationPlan(actions=actions, target_tools=target_tools, ai_powered=False) + + def _render_practice(self, practice: PracticeDeclaration) -> str: + """Render a practice declaration as markdown instruction content.""" + if practice.raw_content: + return practice.raw_content + + lines = [f"# {practice.name}", "", practice.intent, ""] + + if practice.principles: + lines.append("## Principles") + lines.append("") + for p in practice.principles: + lines.append(f"- {p}") + lines.append("") + + if practice.enforcement_patterns: + lines.append("## Enforcement") + lines.append("") + for e in practice.enforcement_patterns: + lines.append(f"- {e}") + lines.append("") + + if practice.examples: + lines.append("## Examples") + lines.append("") + for ex in practice.examples: + lines.append(f"```\n{ex}\n```") + lines.append("") + + return "\n".join(lines) diff --git a/devsync/core/mcp_credential_prompter.py b/devsync/core/mcp_credential_prompter.py new file mode 100644 index 0000000..e790dce --- /dev/null +++ b/devsync/core/mcp_credential_prompter.py @@ -0,0 +1,126 @@ +"""MCP credential prompting for package installation.""" + +import logging +from pathlib import Path +from typing import Optional + +from rich.console import Console +from rich.prompt import Prompt + +from devsync.core.practice import CredentialSpec, MCPDeclaration + +logger = logging.getLogger(__name__) +console = Console() + + +def prompt_mcp_credentials( + mcp_servers: list[MCPDeclaration], + env_path: Optional[Path] = None, +) -> dict[str, dict[str, str]]: + """Prompt the user for MCP server credentials. + + Args: + mcp_servers: MCP server declarations with credential specs. + env_path: Path to write .env file. If None, returns values without writing. + + Returns: + Dict mapping server name → {env_var_name: value}. + """ + all_credentials: dict[str, dict[str, str]] = {} + + for server in mcp_servers: + if not server.credentials: + continue + + console.print(f"\n[bold]MCP Server: {server.name}[/bold]") + console.print(f" {server.description}") + + server_creds: dict[str, str] = {} + for cred in server.credentials: + value = _prompt_single_credential(cred) + if value: + server_creds[cred.name] = value + + if server_creds: + all_credentials[server.name] = server_creds + + if env_path and all_credentials: + _write_env_file(env_path, all_credentials) + + return all_credentials + + +def _prompt_single_credential(cred: CredentialSpec) -> str: + """Prompt for a single credential value. + + Args: + cred: Credential specification. + + Returns: + The credential value entered by the user (empty string if skipped). + """ + required_label = "[red](required)[/red]" if cred.required else "[dim](optional)[/dim]" + console.print(f"\n {required_label} {cred.name}") + console.print(f" [dim]{cred.description}[/dim]") + + default = cred.default or "" + if cred.required: + value = Prompt.ask(f" Enter {cred.name}", default=default if default else None) + return value or "" + else: + value = Prompt.ask(f" Enter {cred.name}", default=default) + return value or "" + + +def _write_env_file(env_path: Path, credentials: dict[str, dict[str, str]]) -> None: + """Write credentials to a .env file. + + Args: + env_path: Path to the .env file. + credentials: Server name → {env_var: value} mapping. + """ + env_path.parent.mkdir(parents=True, exist_ok=True) + + from devsync.utils.dotenv import ensure_env_gitignored, set_env_variable + + ensure_env_gitignored(env_path) + + for server_name, creds in credentials.items(): + for var_name, value in creds.items(): + if value: + set_env_variable(env_path, var_name, value) + logger.info("Wrote %s to %s", var_name, env_path) + + console.print(f"\n[green]Credentials saved to {env_path}[/green]") + + +def build_mcp_config( + server: MCPDeclaration, + credentials: dict[str, str], +) -> dict: + """Build a tool-native MCP server config dict. + + Args: + server: MCP server declaration. + credentials: Resolved credential values {env_var: value}. + + Returns: + Dict suitable for writing to tool-specific MCP config. + """ + config: dict = { + "command": server.command, + "args": server.args, + } + + env: dict[str, str] = {} + env.update(server.env_vars) + + for cred in server.credentials: + value = credentials.get(cred.name, "") + if value: + env[cred.name] = value + + if env: + config["env"] = env + + return config diff --git a/tests/unit/cli/test_extract.py b/tests/unit/cli/test_extract.py new file mode 100644 index 0000000..dd760f1 --- /dev/null +++ b/tests/unit/cli/test_extract.py @@ -0,0 +1,61 @@ +"""Tests for extract CLI command.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from devsync.cli.extract import extract_command +from devsync.core.practice import PracticeDeclaration +from devsync.llm.response_models import ExtractionResult + + +class TestExtractCommand: + def test_extract_invalid_path(self) -> None: + result = extract_command(project_dir="/nonexistent/path") + assert result == 1 + + @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: + output_dir = tmp_path / "output" + mock_extractor = MagicMock() + mock_extractor.extract.return_value = ExtractionResult( + practices=[PracticeDeclaration(name="test", intent="Test practice")], + source_files=["rules/test.md"], + ai_powered=False, + ) + mock_extractor_cls.return_value = mock_extractor + + result = extract_command( + output=str(output_dir), + name="test-pkg", + no_ai=True, + project_dir=str(tmp_path), + ) + + assert result == 0 + manifest_path = output_dir / "devsync-package.yaml" + assert manifest_path.exists() + + manifest = yaml.safe_load(manifest_path.read_text()) + assert manifest["name"] == "test-pkg" + assert manifest["format_version"] == "2.0" + + @patch("devsync.cli.extract.resolve_provider", return_value=None) + @patch("devsync.cli.extract.PracticeExtractor") + @patch("devsync.cli.extract.load_config") + def test_extract_no_api_key_fallback( + self, mock_config: MagicMock, mock_extractor_cls: MagicMock, mock_resolve: MagicMock, tmp_path: Path + ) -> None: + 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)) + + assert result == 0 + mock_extractor_cls.assert_called_once_with(llm_provider=None) diff --git a/tests/unit/cli/test_install_v2.py b/tests/unit/cli/test_install_v2.py new file mode 100644 index 0000000..84443c4 --- /dev/null +++ b/tests/unit/cli/test_install_v2.py @@ -0,0 +1,79 @@ +"""Tests for install v2 CLI command.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from devsync.cli.install_v2 import _get_tool_instruction_path, _resolve_source, install_v2_command + + +class TestResolveSource: + def test_local_directory(self, tmp_path: Path) -> None: + result = _resolve_source(str(tmp_path)) + assert result == tmp_path + + def test_nonexistent_path(self) -> None: + result = _resolve_source("/definitely/not/a/real/path/xyz123") + assert result is None + + +class TestGetToolInstructionPath: + def test_claude_path(self, tmp_path: Path) -> None: + result = _get_tool_instruction_path("claude", tmp_path, "test-rule") + assert result is not None + assert str(result).endswith(".claude/rules/test-rule.md") + + def test_cursor_path(self, tmp_path: Path) -> None: + result = _get_tool_instruction_path("cursor", tmp_path, "test-rule") + assert result is not None + assert str(result).endswith(".cursor/rules/test-rule.mdc") + + def test_unknown_tool_returns_none(self, tmp_path: Path) -> None: + result = _get_tool_instruction_path("unknown-tool", tmp_path, "test") + assert result is None + + +class TestInstallV2Command: + def test_install_nonexistent_source(self) -> None: + result = install_v2_command(source="/nonexistent/package/path") + assert result == 1 + + def test_install_no_manifest(self, tmp_path: Path) -> None: + result = install_v2_command(source=str(tmp_path)) + assert result == 1 + + @patch("devsync.cli.install_v2.find_project_root") + @patch("devsync.cli.install_v2._resolve_tools", return_value=["claude"]) + @patch("devsync.cli.install_v2.Confirm.ask", return_value=False) + def test_install_v1_package_file_copy(self, mock_confirm: MagicMock, mock_tools: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: + project_dir = tmp_path / "project" + project_dir.mkdir() + mock_root.return_value = project_dir + + pkg_dir = tmp_path / "package" + pkg_dir.mkdir() + instructions_dir = pkg_dir / "instructions" + instructions_dir.mkdir() + (instructions_dir / "style.md").write_text("# Style\nUse black.") + + manifest = { + "name": "test-pkg", + "version": "1.0.0", + "description": "Test", + "author": "Dev", + "license": "MIT", + "namespace": "test", + "components": { + "instructions": [{"name": "style", "file": "instructions/style.md"}], + }, + } + (pkg_dir / "ai-config-kit-package.yaml").write_text(yaml.dump(manifest)) + + result = install_v2_command(source=str(pkg_dir), no_ai=True, project_dir=str(project_dir)) + + assert result == 0 + installed = project_dir / ".claude" / "rules" / "style.md" + assert installed.exists() + assert "Use black" in installed.read_text() diff --git a/tests/unit/core/test_adapter.py b/tests/unit/core/test_adapter.py new file mode 100644 index 0000000..65c383b --- /dev/null +++ b/tests/unit/core/test_adapter.py @@ -0,0 +1,141 @@ +"""Tests for PracticeAdapter.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from devsync.core.adapter import PracticeAdapter +from devsync.core.practice import PracticeDeclaration +from devsync.llm.provider import LLMProviderError, LLMResponse + + +class TestPracticeAdapterNoAI: + def test_adapt_no_conflicts(self, tmp_path: Path) -> None: + practices = [ + PracticeDeclaration(name="type-safety", intent="Enforce types", principles=["Use type hints"]), + ] + adapter = PracticeAdapter(llm_provider=None) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert plan.ai_powered is False + assert len(plan.installs) == 1 + assert plan.installs[0].practice_name == "type-safety" + assert "type-safety" in plan.installs[0].content + + def test_adapt_with_existing_rule_skips(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "type-safety.md").write_text("# Existing type rules") + + practices = [ + PracticeDeclaration(name="type-safety", intent="Enforce types"), + ] + adapter = PracticeAdapter(llm_provider=None) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert len(plan.skips) == 1 + assert plan.skips[0].practice_name == "type-safety" + + def test_adapt_mixed_conflicts(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".cursor" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "style.mdc").write_text("# Style rules") + + practices = [ + PracticeDeclaration(name="style", intent="Code style"), + PracticeDeclaration(name="testing", intent="Test coverage"), + ] + adapter = PracticeAdapter(llm_provider=None) + plan = adapter.adapt(practices, tmp_path, ["cursor"]) + + assert len(plan.skips) == 1 + assert len(plan.installs) == 1 + + def test_render_practice_with_raw_content(self, tmp_path: Path) -> None: + practices = [ + PracticeDeclaration(name="custom", intent="Custom rule", raw_content="# My Custom Rule\nDo this."), + ] + adapter = PracticeAdapter(llm_provider=None) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert plan.installs[0].content == "# My Custom Rule\nDo this." + + +class TestPracticeAdapterWithAI: + def test_adapt_install_action(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "existing.md").write_text("# Existing rule") + + practices = [ + PracticeDeclaration(name="security", intent="Security patterns", principles=["No eval()"]), + ] + + llm_response = LLMResponse( + content=json.dumps( + { + "action": "install", + "reason": "No overlap with existing rules", + "file_name": "security.md", + } + ), + model="test", + ) + mock_provider = MagicMock() + mock_provider.complete.return_value = llm_response + + adapter = PracticeAdapter(llm_provider=mock_provider) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert plan.ai_powered is True + assert len(plan.installs) == 1 + assert plan.installs[0].practice_name == "security" + + def test_adapt_merge_action(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "style.md").write_text("# Old style") + + practices = [ + PracticeDeclaration(name="style", intent="Code style"), + ] + + llm_response = LLMResponse( + content=json.dumps( + { + "action": "merge", + "reason": "Overlapping style rules", + "merged_content": "# Merged Style\nOld + new rules", + "file_name": "style.md", + } + ), + model="test", + ) + mock_provider = MagicMock() + mock_provider.complete.return_value = llm_response + + adapter = PracticeAdapter(llm_provider=mock_provider) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert len(plan.merges) == 1 + assert "Merged Style" in plan.merges[0].content + + def test_ai_failure_falls_back_to_install(self, tmp_path: Path) -> None: + rules_dir = tmp_path / ".claude" / "rules" + rules_dir.mkdir(parents=True) + (rules_dir / "existing.md").write_text("# Existing") + + practices = [ + PracticeDeclaration(name="testing", intent="Test rules"), + ] + + mock_provider = MagicMock() + mock_provider.complete.side_effect = LLMProviderError("API error") + + adapter = PracticeAdapter(llm_provider=mock_provider) + plan = adapter.adapt(practices, tmp_path, ["claude"]) + + assert len(plan.installs) == 1 + assert "failed" in plan.installs[0].reason.lower() diff --git a/tests/unit/core/test_mcp_credential_prompter.py b/tests/unit/core/test_mcp_credential_prompter.py new file mode 100644 index 0000000..1997720 --- /dev/null +++ b/tests/unit/core/test_mcp_credential_prompter.py @@ -0,0 +1,76 @@ +"""Tests for MCP credential prompting.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.core.mcp_credential_prompter import build_mcp_config, prompt_mcp_credentials +from devsync.core.practice import CredentialSpec, MCPDeclaration + + +class TestBuildMCPConfig: + def test_basic_config(self) -> None: + server = MCPDeclaration( + name="github", + description="GitHub API", + command="npx", + args=["-y", "server"], + ) + config = build_mcp_config(server, {}) + assert config["command"] == "npx" + assert config["args"] == ["-y", "server"] + assert "env" not in config + + def test_config_with_credentials(self) -> None: + server = MCPDeclaration( + name="github", + description="GitHub API", + command="npx", + args=["-y", "server"], + credentials=[CredentialSpec(name="GITHUB_TOKEN", description="PAT")], + ) + config = build_mcp_config(server, {"GITHUB_TOKEN": "ghp_abc123"}) + assert config["env"]["GITHUB_TOKEN"] == "ghp_abc123" + + def test_config_with_env_vars_and_credentials(self) -> None: + server = MCPDeclaration( + name="db", + description="Database", + command="python", + args=["server.py"], + env_vars={"DB_HOST": "localhost"}, + credentials=[CredentialSpec(name="DB_PASS", description="Password")], + ) + config = build_mcp_config(server, {"DB_PASS": "secret"}) + assert config["env"]["DB_HOST"] == "localhost" + assert config["env"]["DB_PASS"] == "secret" + + def test_empty_credential_not_included(self) -> None: + server = MCPDeclaration( + name="test", + description="Test", + command="cmd", + credentials=[CredentialSpec(name="KEY", description="Key", required=False)], + ) + config = build_mcp_config(server, {"KEY": ""}) + assert "env" not in config or "KEY" not in config.get("env", {}) + + +class TestPromptMCPCredentials: + @patch("devsync.core.mcp_credential_prompter.Prompt.ask", return_value="my-token") + def test_prompts_for_required_credential(self, mock_ask: MagicMock) -> None: + servers = [ + MCPDeclaration( + name="github", + description="GitHub API", + credentials=[CredentialSpec(name="TOKEN", description="Auth token")], + ) + ] + result = prompt_mcp_credentials(servers) + assert result["github"]["TOKEN"] == "my-token" + + def test_no_credentials_returns_empty(self) -> None: + servers = [MCPDeclaration(name="test", description="No creds")] + result = prompt_mcp_credentials(servers) + assert result == {} From 729d7485076ea6868fe78c9c82afd496eb8ff8f6 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:47:44 -0500 Subject: [PATCH 04/14] refactor(cli): update CLI to v2 command surface with simplified list (#73, #74) 6 commands: setup, tools, extract, install, list, uninstall, version. Removed library/template/mcp subcommand groups from main.py. --- devsync/cli/list_v2.py | 93 +++++++ devsync/cli/main.py | 478 ++++++++++----------------------- tests/unit/cli/test_list_v2.py | 44 +++ 3 files changed, 278 insertions(+), 337 deletions(-) create mode 100644 devsync/cli/list_v2.py create mode 100644 tests/unit/cli/test_list_v2.py diff --git a/devsync/cli/list_v2.py b/devsync/cli/list_v2.py new file mode 100644 index 0000000..f630545 --- /dev/null +++ b/devsync/cli/list_v2.py @@ -0,0 +1,93 @@ +"""Simplified list command for v2.""" + +import json as json_module +from pathlib import Path +from typing import Optional + +import typer +from rich.console import Console +from rich.table import Table + +from devsync.storage.package_tracker import PackageTracker +from devsync.utils.project import find_project_root + +console = Console() + + +def list_v2_command( + tool: Optional[str] = None, + json: bool = False, +) -> int: + """List installed packages and instructions. + + Args: + tool: Filter by AI tool name. + json: Output as JSON. + + Returns: + Exit code (0 = success). + """ + project_root = find_project_root(Path.cwd()) + if not project_root: + project_root = Path.cwd() + + tracker = PackageTracker(project_root) + + try: + packages = tracker.list_packages() + except Exception: + packages = [] + + if not packages: + if json: + console.print("[]") + else: + console.print("[dim]No packages installed in this project.[/dim]") + console.print("Use [cyan]devsync install [/cyan] to install packages.") + return 0 + + if tool: + packages = [p for p in packages if _package_has_tool(p, tool)] + + if json: + output = [] + for pkg in packages: + pkg_dict = pkg.to_dict() if hasattr(pkg, "to_dict") else {"name": str(pkg)} + output.append(pkg_dict) + console.print(json_module.dumps(output, indent=2)) + return 0 + + table = Table(title="Installed Packages") + table.add_column("Package", style="cyan") + table.add_column("Version") + table.add_column("Components", justify="right") + table.add_column("Status") + + for pkg in packages: + name = getattr(pkg, "name", str(pkg)) + version = getattr(pkg, "version", "?") + components = getattr(pkg, "components", []) + component_count = len(components) if isinstance(components, list) else 0 + status = getattr(pkg, "status", "installed") + status_style = "green" if status == "installed" or status == "COMPLETE" else "yellow" + + table.add_row( + name, + str(version), + str(component_count), + f"[{status_style}]{status}[/{status_style}]", + ) + + console.print(table) + return 0 + + +def _package_has_tool(pkg: object, tool_name: str) -> bool: + """Check if a package has components for a specific tool.""" + components = getattr(pkg, "components", []) + if isinstance(components, list): + for comp in components: + comp_tool = getattr(comp, "ai_tool", None) or getattr(comp, "tool", None) + if comp_tool and str(comp_tool).lower() == tool_name.lower(): + return True + return True diff --git a/devsync/cli/main.py b/devsync/cli/main.py index bc2d62d..7e87285 100644 --- a/devsync/cli/main.py +++ b/devsync/cli/main.py @@ -1,388 +1,213 @@ -"""Main CLI application entry point.""" +"""Main CLI application entry point — v2 command surface.""" from typing import Optional import typer -from devsync.cli.delete import delete_from_library -from devsync.cli.download import download_instructions -from devsync.cli.install_new import install_instruction_unified -from devsync.cli.list import list_available, list_installed, list_library -from devsync.cli.mcp_configure import mcp_configure_command -from devsync.cli.mcp_install import mcp_install_command -from devsync.cli.mcp_sync import mcp_sync_command -from devsync.cli.package import package_app -from devsync.cli.template import template_app -from devsync.cli.template_backup import ( - backup_cleanup_command, - backup_list_command, - backup_restore_command, -) -from devsync.cli.template_init import init_command as template_init_command -from devsync.cli.template_install import install_command as template_install_command -from devsync.cli.template_list import list_command as template_list_command -from devsync.cli.template_uninstall import uninstall_command as template_uninstall_command -from devsync.cli.template_update import update_command as template_update_command -from devsync.cli.template_validate import validate_command as template_validate_command from devsync.cli.tools import show_tools -from devsync.cli.uninstall import uninstall_instruction -from devsync.cli.update import update_repository app = typer.Typer( name="devsync", - help="Distribute and sync dev tool configurations across teams", + help="AI-powered config distribution for AI coding tools.", add_completion=False, ) -# Create list subcommand group -list_app = typer.Typer(help="List instructions") -app.add_typer(list_app, name="list") - -# Create package subcommand group -app.add_typer(package_app, name="package") - -# Create template subcommand group -app.add_typer(template_app, name="template") -# Create backup subcommand group under template -backup_app = typer.Typer(help="Manage template backups") -template_app.add_typer(backup_app, name="backup") +@app.command() +def setup() -> None: + """Configure LLM provider for AI-powered features. -# Create mcp subcommand group -mcp_app = typer.Typer(help="Manage MCP server configurations") -app.add_typer(mcp_app, name="mcp") + Interactive setup to select provider (Anthropic, OpenAI, OpenRouter), + validate API key, and save preferences. -# Register mcp commands -mcp_app.command(name="install")(mcp_install_command) -mcp_app.command(name="configure")(mcp_configure_command) -mcp_app.command(name="sync")(mcp_sync_command) + Example: + devsync setup + """ + from devsync.cli.setup import setup_command -# Register template commands -template_app.command(name="init")(template_init_command) -template_app.command(name="install")(template_install_command) -template_app.command(name="list")(template_list_command) -template_app.command(name="update")(template_update_command) -template_app.command(name="uninstall")(template_uninstall_command) -template_app.command(name="validate")(template_validate_command) + exit_code = setup_command() + raise typer.Exit(code=exit_code) -# Register backup commands -backup_app.command(name="list")(backup_list_command) -backup_app.command(name="cleanup")(backup_cleanup_command) -backup_app.command(name="restore")(backup_restore_command) +@app.command() +def tools() -> None: + """Show detected AI coding tools. -@list_app.callback(invoke_without_command=True) -def list_callback(ctx: typer.Context) -> None: - """List instructions (available, installed, or in library).""" - # If no subcommand was provided, show help - if ctx.invoked_subcommand is None: - typer.echo(ctx.get_help()) - raise typer.Exit(0) + Display which AI coding tools are installed on your system + and where their configuration directories are located. + """ + exit_code = show_tools() + raise typer.Exit(code=exit_code) @app.command() -def install( - names: Optional[list[str]] = typer.Argument( +def extract( + output: Optional[str] = typer.Option( None, - help="Instruction name(s) to install (use source/name for disambiguation). Can specify multiple.", + "--output", + "-o", + help="Output directory for the package (default: ./devsync-package/)", ), - source: Optional[str] = typer.Option( + name: Optional[str] = typer.Option( None, - "--from", - "-f", - help="Source URL or path for direct install (bypasses library)", + "--name", + "-n", + help="Package name (default: project directory name)", ), - tools: Optional[list[str]] = typer.Option( - None, - "--tool", - "-t", - help="AI tool(s) to install to (cursor, copilot, windsurf, claude). Can specify multiple times.", + no_ai: bool = typer.Option( + False, + "--no-ai", + help="Force file-copy mode (no LLM calls)", ), - conflict: str = typer.Option( - "prompt", - "--conflict", - "-c", - help="Conflict resolution strategy (prompt [default], skip, rename, overwrite)", + project_dir: Optional[str] = typer.Option( + None, + "--project", + "-p", + help="Project directory to extract from (default: current directory)", ), - bundle: bool = typer.Option( - False, - "--bundle", - "-b", - help="Install as bundle (multiple instructions)", + upgrade: Optional[str] = typer.Option( + None, + "--upgrade", + help="Convert a v1 package to v2 format", ), ) -> None: - """ - Install instructions from your library or directly from a source. + """Extract practices from a project into a shareable package. - LIBRARY WORKFLOW (Recommended): - # Browse and select instructions with TUI - devsync install + Reads your project's AI tool configs (rules, MCP servers, hooks, commands) + and produces a devsync-package.yaml with abstract practice declarations. - # Install specific instruction from library - devsync install python-best-practices - - # Install from specific source (if multiple sources have same name) - devsync install company/python-best-practices - - # Install multiple instructions at once - devsync install python-style testing-guide api-design - - # Install to specific tools only - devsync install python-style --tool cursor --tool windsurf + Examples: + # AI-powered extraction + devsync extract - DIRECT INSTALL (Bypasses library): - # Install directly from source URL - devsync install python-style --from https://github.com/company/instructions + # File-copy mode (no AI) + devsync extract --no-ai - # Install bundle directly - devsync install python-backend --bundle --from https://github.com/company/instructions + # Custom output and name + devsync extract --output ./my-package --name team-standards - TIP: Download to your library first for better management: - devsync download --from https://github.com/company/instructions + # Upgrade v1 package to v2 + devsync extract --upgrade ./old-package """ - exit_code = install_instruction_unified( - names=names, - repo=source, # Keep backend param name for now - tools=tools, - conflict_strategy=conflict, - bundle=bundle, - ) + from devsync.cli.extract import extract_command + + if upgrade: + exit_code = extract_command( + output=output, + name=name, + no_ai=no_ai, + project_dir=upgrade, + ) + else: + exit_code = extract_command( + output=output, + name=name, + no_ai=no_ai, + project_dir=project_dir, + ) raise typer.Exit(code=exit_code) @app.command() -def download( - source: str = typer.Option( +def install( + source: str = typer.Argument( ..., - "--from", - "-f", - help="Source URL or local directory path", - ), - ref: Optional[str] = typer.Option( - None, - "--ref", - "-r", - help="Git reference (tag, branch, or commit) to download", + help="Package source: Git URL, local path, or package directory", ), - alias: Optional[str] = typer.Option( + tool: Optional[list[str]] = typer.Option( None, - "--as", - "-a", - help="Friendly alias for this source (auto-generated if not provided)", + "--tool", + "-t", + help="Target AI tool(s). Auto-detects if not specified.", ), - force: bool = typer.Option( + no_ai: bool = typer.Option( False, - "--force", - help="Re-download even if already in library", + "--no-ai", + help="Disable AI-powered adaptation", + ), + conflict: str = typer.Option( + "prompt", + "--conflict", + "-c", + help="Conflict strategy: prompt, skip, overwrite, rename", + ), + project_dir: Optional[str] = typer.Option( + None, + "--project", + "-p", + help="Target project directory (default: current directory)", ), ) -> None: - """ - Download instructions from a source into your local library. + """Install a package into the current project. - This downloads and caches instructions locally without installing them. - After downloading, use 'devsync install' to install instructions. + Accepts Git URLs, local paths, or extracted package directories. + AI-powered adaptation merges incoming practices with existing rules. Examples: + # Install from local package + devsync install ./team-standards - # Download from GitHub (auto-generates alias) - devsync download --from github.com/company/instructions - - # Download specific version - devsync download --from github.com/company/instructions --ref v1.0.0 + # Install from Git + devsync install https://github.com/company/standards - # Download from branch - devsync download --from github.com/company/instructions --ref main + # Install to specific tools + devsync install ./package --tool claude --tool cursor - # Download with custom alias - devsync download --from github.com/company/instructions --as company + # File-copy mode (no AI) + devsync install ./package --no-ai - # Download from local folder - devsync download --from ./my-instructions --as local - - # Force re-download - devsync download --from github.com/company/instructions --force + # Skip conflicts + devsync install ./package --conflict skip """ - exit_code = download_instructions(repo=source, ref=ref, force=force, alias=alias) - raise typer.Exit(code=exit_code) - - -@list_app.command("available") -def list_available_cmd( - source: str = typer.Option(..., "--from", "-f", help="Source URL or local directory path"), - tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag"), - bundles_only: bool = typer.Option(False, "--bundles-only", help="Show only bundles"), - instructions_only: bool = typer.Option(False, "--instructions-only", help="Show only instructions"), -) -> None: - """ - List available instructions from a source (without downloading). - - Examples: - - # List from Git repository - devsync list available --from github.com/company/instructions - - # List from local folder - devsync list available --from ./my-instructions - - # Filter by tag - devsync list available --from github.com/company/instructions --tag python - - # Show only bundles - devsync list available --from github.com/company/instructions --bundles-only - """ - exit_code = list_available( - repo=source, # Keep backend param name for now - tag=tag, - bundles_only=bundles_only, - instructions_only=instructions_only, + from devsync.cli.install_v2 import install_v2_command + + exit_code = install_v2_command( + source=source, + tool=tool, + no_ai=no_ai, + conflict=conflict, + project_dir=project_dir, ) raise typer.Exit(code=exit_code) -@list_app.command("installed") -def list_installed_cmd( +@app.command(name="list") +def list_cmd( tool: Optional[str] = typer.Option( None, "--tool", "-t", - help="Filter by AI tool (cursor, copilot, windsurf, claude)", - ), - source: Optional[str] = typer.Option( - None, - "--source", - "-s", - help="Filter by source alias or name", - ), -) -> None: - """ - List installed instructions in your AI tools. - - Examples: - - # List all installed instructions - devsync list installed - - # Filter by AI tool - devsync list installed --tool cursor - - # Filter by source - devsync list installed --source company - """ - exit_code = list_installed(tool=tool, repo=source) # Keep backend param name for now - raise typer.Exit(code=exit_code) - - -@list_app.command("library") -def list_library_cmd( - source: Optional[str] = typer.Option( - None, - "--source", - "-s", - help="Filter by source alias", + help="Filter by AI tool name", ), - instructions: bool = typer.Option( + json: bool = typer.Option( False, - "--instructions", - "-i", - help="Show individual instructions instead of repositories", + "--json", + help="Output as JSON", ), ) -> None: - """ - List sources and instructions in your local library. - - Examples: + """List installed packages and instructions. - # List all sources in library - devsync list library - - # Show individual instructions - devsync list library --instructions - - # Filter by source - devsync list library --source company - """ - exit_code = list_library(repo_filter=source, show_instructions=instructions) - raise typer.Exit(code=exit_code) - - -@app.command() -def update( - namespace: Optional[str] = typer.Option( - None, - "--namespace", - "-n", - help="Repository namespace to update", - ), - all_repos: bool = typer.Option( - False, - "--all", - "-a", - help="Update all repositories in library", - ), -) -> None: - """ - Update downloaded instructions to their latest versions. - - This re-downloads instructions from their sources, - ensuring you have the latest versions in your library. + Shows all packages installed in the current project with component breakdown. Examples: - - # Update a specific source - devsync update --namespace github.com_company_instructions - - # Update all sources - devsync update --all - - # List sources to find namespace - devsync list library - """ - exit_code = update_repository(namespace=namespace, all_repos=all_repos) - raise typer.Exit(code=exit_code) - - -@app.command() -def delete( - namespace: str = typer.Argument( - ..., - help="Repository namespace to delete from library", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Skip confirmation prompt", - ), -) -> None: + devsync list + devsync list --tool claude + devsync list --json """ - Delete a source from your local library. - - This removes the downloaded instructions from your library but does NOT - uninstall them from your AI tools. To uninstall, use 'devsync uninstall'. - - Examples: + from devsync.cli.list_v2 import list_v2_command - # Delete a source - devsync delete github.com_company_instructions - - # Skip confirmation - devsync delete github.com_company_instructions --force - - # List sources to find namespace - devsync list library - """ - exit_code = delete_from_library(namespace=namespace, force=force) + exit_code = list_v2_command(tool=tool, json=json) raise typer.Exit(code=exit_code) @app.command() def uninstall( - name: str = typer.Argument(..., help="Instruction name to uninstall"), + name: str = typer.Argument(..., help="Package name to uninstall"), tool: Optional[str] = typer.Option( None, "--tool", "-t", - help="AI tool to uninstall from (cursor, copilot, windsurf, claude)", + help="AI tool to uninstall from", ), force: bool = typer.Option( False, @@ -391,35 +216,16 @@ def uninstall( help="Skip confirmation prompt", ), ) -> None: - """ - Uninstall an instruction from your AI tools. - - Removes instructions from project level only. + """Uninstall a package from the current project. Examples: - - # Uninstall from all tools - devsync uninstall python-best-practices - - # Uninstall from specific tool - devsync uninstall python-best-practices --tool cursor - - # Skip confirmation - devsync uninstall python-best-practices --force + devsync uninstall team-standards + devsync uninstall team-standards --tool cursor + devsync uninstall team-standards --force """ - exit_code = uninstall_instruction(name=name, tool=tool, force=force) - raise typer.Exit(code=exit_code) - + from devsync.cli.uninstall import uninstall_instruction -@app.command() -def tools() -> 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 = uninstall_instruction(name=name, tool=tool, force=force) raise typer.Exit(code=exit_code) @@ -429,22 +235,20 @@ def version() -> None: from importlib.metadata import version as get_version try: - version = get_version("devsync") + ver = get_version("devsync") except Exception: - version = "unknown" + ver = "unknown" - typer.echo(f"DevSync version {version}") + typer.echo(f"DevSync version {ver}") @app.callback(invoke_without_command=True) def main(ctx: typer.Context) -> None: - """ - DevSync - Distribute and sync dev tool configurations across teams. + """DevSync — AI-powered config distribution for AI coding tools. - Manage AI coding assistant configurations for Claude Code, Cursor, - Windsurf, GitHub Copilot, Cline, Kiro, and Roo Code. + Extract practices from your project, share them as packages, + and install them into any supported AI coding tool. """ - # If no command was provided, show help if ctx.invoked_subcommand is None: typer.echo(ctx.get_help()) raise typer.Exit(0) diff --git a/tests/unit/cli/test_list_v2.py b/tests/unit/cli/test_list_v2.py new file mode 100644 index 0000000..e9de6f3 --- /dev/null +++ b/tests/unit/cli/test_list_v2.py @@ -0,0 +1,44 @@ +"""Tests for v2 list command.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from devsync.cli.list_v2 import list_v2_command + + +class TestListV2Command: + @patch("devsync.cli.list_v2.find_project_root", return_value=None) + @patch("devsync.cli.list_v2.PackageTracker") + def test_no_packages(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: + mock_tracker_cls.return_value.list_packages.return_value = [] + result = list_v2_command() + assert result == 0 + + @patch("devsync.cli.list_v2.find_project_root", return_value=None) + @patch("devsync.cli.list_v2.PackageTracker") + def test_no_packages_json(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: + mock_tracker_cls.return_value.list_packages.return_value = [] + result = list_v2_command(json=True) + assert result == 0 + + @patch("devsync.cli.list_v2.find_project_root", return_value=None) + @patch("devsync.cli.list_v2.PackageTracker") + def test_with_packages(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: + mock_pkg = MagicMock() + mock_pkg.name = "test-pkg" + mock_pkg.version = "1.0.0" + mock_pkg.components = [] + mock_pkg.status = "COMPLETE" + mock_tracker_cls.return_value.list_packages.return_value = [mock_pkg] + + result = list_v2_command() + assert result == 0 + + @patch("devsync.cli.list_v2.find_project_root", return_value=None) + @patch("devsync.cli.list_v2.PackageTracker") + def test_tracker_exception(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: + mock_tracker_cls.return_value.list_packages.side_effect = Exception("No file") + result = list_v2_command() + assert result == 0 From 88d6083e23bc86233bf16714680ff8dc33451625 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:51:53 -0500 Subject: [PATCH 05/14] chore(core): remove deprecated modules and dead tests (#75) Remove library system, template system, TUI, legacy install/list/update/delete, standalone MCP commands, and package_create CLI. Also remove 24 test files that reference deleted modules. --- devsync/cli/delete.py | 118 --- devsync/cli/download.py | 274 ----- devsync/cli/install.py | 237 ----- devsync/cli/install_new.py | 937 ------------------ devsync/cli/list.py | 275 ----- devsync/cli/mcp_configure.py | 233 ----- devsync/cli/mcp_install.py | 167 ---- devsync/cli/mcp_sync.py | 166 ---- devsync/cli/package.py | 386 -------- devsync/cli/package_create.py | 323 ------ devsync/cli/package_install.py | 474 --------- devsync/cli/template.py | 19 - devsync/cli/template_backup.py | 262 ----- devsync/cli/template_init.py | 499 ---------- devsync/cli/template_install.py | 263 ----- devsync/cli/template_list.py | 172 ---- devsync/cli/template_uninstall.py | 146 --- devsync/cli/template_update.py | 225 ----- devsync/cli/template_validate.py | 234 ----- devsync/cli/update.py | 309 ------ devsync/core/template_manifest.py | 283 ------ devsync/storage/library.py | 429 -------- devsync/storage/template_library.py | 231 ----- devsync/storage/template_tracker.py | 297 ------ devsync/tui/installer.py | 511 ---------- tests/e2e/test_basic_workflows.py | 307 ------ tests/e2e/test_comprehensive.py | 703 ------------- tests/e2e/test_conflict_resolution.py | 534 ---------- tests/e2e/test_git_operations.py | 513 ---------- tests/e2e/test_multi_package.py | 560 ----------- tests/e2e/test_version_management.py | 425 -------- .../packages/test_package_install.py | 567 ----------- tests/integration/test_library.py | 491 --------- tests/unit/cli/test_delete.py | 254 ----- tests/unit/cli/test_download.py | 364 ------- tests/unit/cli/test_install.py | 551 ---------- tests/unit/cli/test_list.py | 609 ------------ tests/unit/cli/test_package.py | 235 ----- tests/unit/cli/test_update.py | 614 ------------ .../unit/packages/test_package_create_cli.py | 367 ------- .../packages/test_package_install_coverage.py | 241 ----- tests/unit/test_cli_delete.py | 170 ---- tests/unit/test_cli_install_new.py | 673 ------------- tests/unit/test_library_versioning.py | 210 ---- tests/unit/test_template_init.py | 254 ----- tests/unit/test_template_library.py | 413 -------- tests/unit/test_template_manifest.py | 552 ----------- tests/unit/test_template_tracker.py | 482 --------- tests/unit/test_template_validate.py | 378 ------- 49 files changed, 17937 deletions(-) delete mode 100644 devsync/cli/delete.py delete mode 100644 devsync/cli/download.py delete mode 100644 devsync/cli/install.py delete mode 100644 devsync/cli/install_new.py delete mode 100644 devsync/cli/list.py delete mode 100644 devsync/cli/mcp_configure.py delete mode 100644 devsync/cli/mcp_install.py delete mode 100644 devsync/cli/mcp_sync.py delete mode 100644 devsync/cli/package.py delete mode 100644 devsync/cli/package_create.py delete mode 100644 devsync/cli/package_install.py delete mode 100644 devsync/cli/template.py delete mode 100644 devsync/cli/template_backup.py delete mode 100644 devsync/cli/template_init.py delete mode 100644 devsync/cli/template_install.py delete mode 100644 devsync/cli/template_list.py delete mode 100644 devsync/cli/template_uninstall.py delete mode 100644 devsync/cli/template_update.py delete mode 100644 devsync/cli/template_validate.py delete mode 100644 devsync/cli/update.py delete mode 100644 devsync/core/template_manifest.py delete mode 100644 devsync/storage/library.py delete mode 100644 devsync/storage/template_library.py delete mode 100644 devsync/storage/template_tracker.py delete mode 100644 devsync/tui/installer.py delete mode 100644 tests/e2e/test_basic_workflows.py delete mode 100644 tests/e2e/test_comprehensive.py delete mode 100644 tests/e2e/test_conflict_resolution.py delete mode 100644 tests/e2e/test_git_operations.py delete mode 100644 tests/e2e/test_multi_package.py delete mode 100644 tests/e2e/test_version_management.py delete mode 100644 tests/integration/packages/test_package_install.py delete mode 100644 tests/integration/test_library.py delete mode 100644 tests/unit/cli/test_delete.py delete mode 100644 tests/unit/cli/test_download.py delete mode 100644 tests/unit/cli/test_install.py delete mode 100644 tests/unit/cli/test_list.py delete mode 100644 tests/unit/cli/test_package.py delete mode 100644 tests/unit/cli/test_update.py delete mode 100644 tests/unit/packages/test_package_create_cli.py delete mode 100644 tests/unit/packages/test_package_install_coverage.py delete mode 100644 tests/unit/test_cli_delete.py delete mode 100644 tests/unit/test_cli_install_new.py delete mode 100644 tests/unit/test_library_versioning.py delete mode 100644 tests/unit/test_template_init.py delete mode 100644 tests/unit/test_template_library.py delete mode 100644 tests/unit/test_template_manifest.py delete mode 100644 tests/unit/test_template_tracker.py delete mode 100644 tests/unit/test_template_validate.py diff --git a/devsync/cli/delete.py b/devsync/cli/delete.py deleted file mode 100644 index 55a6180..0000000 --- a/devsync/cli/delete.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Delete command for removing instructions from library.""" - -import typer -from rich.console import Console -from rich.prompt import Confirm - -from devsync.storage.library import LibraryManager -from devsync.storage.tracker import InstallationTracker -from devsync.utils.ui import print_error, print_success, print_warning - -console = Console() - -app = typer.Typer() - - -def delete_from_library( - namespace: str, - force: bool = False, -) -> int: - """ - Delete a repository from the library. - - Args: - namespace: Repository namespace to delete - force: Skip confirmation - - Returns: - Exit code (0 = success) - """ - library = LibraryManager() - tracker = InstallationTracker() - - # Check if repository exists - repo = library.get_repository(namespace) - if not repo: - print_error(f"Repository not found: {namespace}") - print_error("Use 'devsync list library' to see available repositories") - return 1 - - # Check if any instructions from this repo are currently installed - installed_records = tracker.list_installations() - installed_from_repo = [ - record for record in installed_records if any(inst.repo_namespace == namespace for inst in repo.instructions) - ] - - if installed_from_repo and not force: - print_warning( - f"\n⚠️ Warning: {len(installed_from_repo)} instruction(s) from this repository are currently installed:" - ) - for record in installed_from_repo[:5]: # Show first 5 - console.print(f" - {record.instruction_name} ({record.ai_tool.value})") - if len(installed_from_repo) > 5: - console.print(f" ... and {len(installed_from_repo) - 5} more") - console.print() - print_warning("Deleting from library will not uninstall them from your AI tools.\n") - - # Confirm deletion - if not force: - console.print(f"\n[bold]Repository:[/bold] {repo.name}") - console.print(f"[bold]Namespace:[/bold] {repo.namespace}") - console.print(f"[bold]Instructions:[/bold] {len(repo.instructions)}\n") - - confirmed = Confirm.ask("[yellow]Are you sure you want to delete this repository from your library?[/yellow]") - - if not confirmed: - console.print("[dim]Cancelled[/dim]") - return 0 - - # Delete repository - success = library.remove_repository(namespace) - - if success: - print_success( - f"✓ Deleted repository '{repo.name}' from library\n" - f" {len(repo.instructions)} instruction(s) removed from library" - ) - if installed_from_repo: - print_warning( - f"\n Note: {len(installed_from_repo)} instruction(s) are still installed in your AI tools.\n" - f" Use 'devsync uninstall ' to remove them." - ) - return 0 - else: - print_error(f"Failed to delete repository: {namespace}") - return 1 - - -@app.command(name="delete") -def delete_command( - namespace: str = typer.Argument( - ..., - help="Repository namespace to delete from library", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Skip confirmation prompt", - ), -) -> None: - """ - Delete a repository from your local library. - - This removes the downloaded instructions from your library but does NOT - uninstall them from your AI tools. To uninstall, use 'devsync uninstall'. - - Examples: - # Delete a repository - devsync delete github.com_company_instructions - - # Skip confirmation - devsync delete github.com_company_instructions --force - - # List repositories to find namespace - devsync list library - """ - exit_code = delete_from_library(namespace=namespace, force=force) - raise typer.Exit(code=exit_code) diff --git a/devsync/cli/download.py b/devsync/cli/download.py deleted file mode 100644 index 2a29bb5..0000000 --- a/devsync/cli/download.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Download command for fetching instructions into the library.""" - -import shutil -from datetime import datetime -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn - -from devsync.core.checksum import calculate_file_checksum -from devsync.core.git_operations import GitOperations, RepositoryOperationError -from devsync.core.models import LibraryInstruction -from devsync.core.repository import RepositoryParser -from devsync.storage.library import LibraryManager -from devsync.utils.ui import print_error, print_success - -console = Console() - -app = typer.Typer() - - -def download_instructions( - repo: str, - force: bool = False, - alias: Optional[str] = None, - ref: Optional[str] = None, -) -> int: - """ - Download instructions from a repository into the local library. - - Args: - repo: Repository URL or local path - force: If True, re-download even if already in library - alias: User-friendly alias for this source (auto-generated if not provided) - ref: Git reference (tag, branch, or commit) to download - - Returns: - Exit code (0 = success) - """ - library = LibraryManager() - - # Display what we're downloading - if ref: - console.print(f"\n[bold]Downloading from:[/bold] {repo} [bold cyan]@{ref}[/bold cyan]\n") - else: - console.print(f"\n[bold]Downloading from:[/bold] {repo}\n") - - # Determine if local or remote - is_local = GitOperations.is_local_path(repo) - temp_repo_path = None # Track temp directory for cleanup - ref_type = None # Track the reference type - - try: - # Validate and detect ref type for remote repositories - if ref and not is_local: - try: - validated_ref, ref_type = GitOperations.detect_ref_type(repo, ref) - ref = validated_ref # Use the validated reference - except RepositoryOperationError as e: - if e.error_type == "invalid_reference": - print_error(f"Invalid reference '{ref}': not found in repository") - return 1 - elif e.error_type == "network_error": - print_error("Network error: unable to access repository") - return 1 - else: - print_error(f"Failed to validate reference: {e}") - return 1 - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - if is_local: - if ref: - print_error("Version references (--ref) are not supported for local repositories") - return 1 - progress.add_task("Loading local repository...", total=None) - repo_path = Path(repo).resolve() - else: - if ref: - task = progress.add_task(f"Cloning repository at {ref}...", total=None) - else: - task = progress.add_task("Cloning repository...", total=None) - - # Use new clone_at_ref for versioned cloning - import tempfile - from pathlib import Path as PathlibPath - - temp_dir = PathlibPath(tempfile.mkdtemp(prefix="devsync-")) - - try: - GitOperations.clone_at_ref(repo, temp_dir, ref, ref_type) - repo_path = temp_dir - temp_repo_path = repo_path # Save for cleanup - except RepositoryOperationError as e: - if temp_dir.exists(): - shutil.rmtree(temp_dir, ignore_errors=True) - print_error(f"Failed to clone repository: {e}") - return 1 - - progress.update(task, completed=True) - - # Parse repository - task = progress.add_task("Parsing repository metadata...", total=None) - parser = RepositoryParser(repo_path) - repository = parser.parse() - repository.url = repo - progress.update(task, completed=True) - - # Generate versioned namespace if ref is specified - repo_name = repository.metadata.get("name", "Unknown") - if ref: - repo_namespace = library.get_versioned_namespace(repo, ref) - else: - repo_namespace = library.get_repo_namespace(repo, repo_name) - - # Check if this specific version already exists - existing_repo = library.get_repository(repo_namespace) - - if existing_repo and not force: - if ref: - print_error( - f"Version '{ref}' of '{repo_name}' already exists in library.\n" f"Use --force to re-download." - ) - else: - print_error( - f"Source '{existing_repo.alias or repo_name}' already exists in library.\n" - f"Use --force to re-download." - ) - return 1 - - # Prepare library instructions - library_instructions = [] - library_repo_dir = library.library_dir / repo_namespace - repo_dir = library_repo_dir / "instructions" - repo_dir.mkdir(parents=True, exist_ok=True) - - console.print("\n[bold]Copying instructions to library...[/bold]\n") - - for instruction in repository.instructions: - # Copy instruction file to library - source_file = repo_path / instruction.file_path - if not source_file.exists(): - print_error(f"Warning: File not found: {instruction.file_path}") - continue - - dest_file = repo_dir / f"{instruction.name}.md" - shutil.copy2(source_file, dest_file) - - # Calculate checksum - checksum = calculate_file_checksum(str(dest_file)) - - # Create library instruction - lib_inst = LibraryInstruction( - id=f"{repo_namespace}/{instruction.name}", - name=instruction.name, - description=instruction.description, - repo_namespace=repo_namespace, - repo_url=repo, - repo_name=repo_name, - author=repository.metadata.get("author", "Unknown"), - version=repository.metadata.get("version", "1.0.0"), - file_path=str(dest_file), - tags=instruction.tags, - downloaded_at=datetime.now(), - checksum=checksum, - ) - library_instructions.append(lib_inst) - - console.print(f" ✓ {instruction.name}") - - # Preserve .git directory for Git sources to enable updates - if not is_local: - git_dir = repo_path / ".git" - if git_dir.exists(): - dest_git_dir = library_repo_dir / ".git" - if dest_git_dir.exists(): - shutil.rmtree(dest_git_dir) - shutil.copytree(git_dir, dest_git_dir) - - # Add to library - library_repo = library.add_repository( - repo_name=repo_name, - repo_description=repository.metadata.get("description", ""), - repo_url=repo, - repo_author=repository.metadata.get("author", "Unknown"), - repo_version=repository.metadata.get("version", "1.0.0"), - instructions=library_instructions, - alias=alias, - namespace=repo_namespace, - ) - - # Build success message - success_msg = f"\n✓ Downloaded {len(library_instructions)} instruction(s) from '{repo_name}'" - if ref and ref_type: - ref_type_badge = {"tag": "📌", "branch": "🌿", "commit": "📍"}.get(ref_type.value, "") - success_msg += f" {ref_type_badge} {ref}" - success_msg += f"\n Alias: {library_repo.alias}\n" f" Namespace: {repo_namespace}\n" - if ref: - ref_labels = {"tag": "Tag", "branch": "Branch", "commit": "Commit"} - ref_label = ref_labels.get(ref_type.value if ref_type else "", "Ref") - success_msg += f" {ref_label}: {ref}\n" - success_msg += ( - " Use 'devsync list library' to see all downloaded instructions\n" - " Use 'devsync install' to install into your AI tools" - ) - print_success(success_msg) - - return 0 - - except FileNotFoundError as e: - print_error(f"Repository metadata file not found: {e}") - return 1 - except Exception as e: - print_error(f"Failed to download: {e}") - return 1 - finally: - # Clean up temp directory if not local - if temp_repo_path and not is_local: - GitOperations.cleanup_repository(temp_repo_path, is_temp=True) - - -@app.command(name="download") -def download_command( - repo: str = typer.Option( - ..., - "--repo", - "-r", - help="Repository URL or local path to download from", - ), - ref: Optional[str] = typer.Option( - None, - "--ref", - help="Git reference (tag, branch, or commit) to download", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Re-download even if already in library", - ), -) -> None: - """ - Download instructions from a repository into your local library. - - This downloads and caches instructions locally without installing them. - After downloading, use 'devsync install' to select and install - instructions into your AI coding tools. - - Examples: - # Download from GitHub (default branch) - devsync download --repo https://github.com/company/instructions - - # Download specific tag version - devsync download --repo https://github.com/company/instructions --ref v1.0.0 - - # Download from specific branch - devsync download --repo https://github.com/company/instructions --ref main - - # Download from specific commit - devsync download --repo https://github.com/company/instructions --ref abc123def - - # Download from local folder (no --ref support) - devsync download --repo ./my-instructions - - # Force re-download - devsync download --repo https://github.com/company/instructions --force - """ - exit_code = download_instructions(repo=repo, ref=ref, force=force) - raise typer.Exit(code=exit_code) diff --git a/devsync/cli/install.py b/devsync/cli/install.py deleted file mode 100644 index 57fb955..0000000 --- a/devsync/cli/install.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Install command implementation.""" - -from datetime import datetime -from pathlib import Path -from typing import Optional - -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn - -from devsync.ai_tools.base import AITool -from devsync.ai_tools.detector import get_detector -from devsync.core.checksum import ChecksumValidator -from devsync.core.conflict_resolution import ( - ConflictResolver, -) -from devsync.core.git_operations import GitOperations -from devsync.core.models import ( - ConflictResolution, - InstallationRecord, - InstallationScope, -) -from devsync.core.repository import RepositoryParser -from devsync.storage.tracker import InstallationTracker -from devsync.utils.project import find_project_root -from devsync.utils.validation import is_valid_git_url, normalize_repo_url - -console = Console() - - -def install_instruction( - name: str, - repo: str, - tool: Optional[str] = None, - conflict_strategy: str = "skip", - bundle: bool = False, -) -> int: - """ - Install an instruction from a Git repository. - - All installations are at project level. - - Args: - name: Instruction or bundle name to install - repo: Git repository URL - tool: AI tool to install to (cursor, copilot, etc.) - conflict_strategy: How to handle conflicts (skip, rename, overwrite) - bundle: Whether this is a bundle installation - - Returns: - Exit code (0 for success, 1 for error) - """ - # Validate repository URL - if not is_valid_git_url(repo): - console.print(f"[red]Error:[/red] Invalid Git repository URL: {repo}") - return 1 - - # Parse conflict strategy - try: - strategy = ConflictResolution(conflict_strategy.lower()) - except ValueError: - console.print( - f"[red]Error:[/red] Invalid conflict strategy: {conflict_strategy}. " - f"Must be 'skip', 'rename', or 'overwrite'." - ) - return 1 - - # Always use project scope - install_scope = InstallationScope.PROJECT - - # Detect project root - project_root = find_project_root() - if project_root is None: - console.print( - "[red]Error:[/red] Could not detect project root. " - "Make sure you're running this command from within a project directory." - ) - return 1 - console.print(f"Detected project root: [cyan]{project_root}[/cyan]") - - # Check Git is installed - if not GitOperations.is_git_installed(): - console.print("[red]Error:[/red] Git is not installed. " "Please install Git and try again.") - return 1 - - # Determine AI tool - ai_tool = _get_ai_tool(tool) - if ai_tool is None: - console.print("[red]Error:[/red] Could not determine AI coding tool. " "Please specify with --tool flag.") - return 1 - - # Validate AI tool - validation_error = ai_tool.validate_installation() - if validation_error: - console.print(f"[red]Error:[/red] {validation_error}") - return 1 - - console.print(f"Installing to [cyan]{ai_tool.tool_name}[/cyan]...") - - # Clone repository or use local path - git_ops = GitOperations() - is_local = git_ops.is_local_path(repo) - - if is_local: - # Use local directory directly - try: - repo_path = git_ops.clone_repository(repo) - console.print(f"Using local directory: [cyan]{repo_path}[/cyan]") - except Exception as e: - console.print(f"[red]Error:[/red] Failed to access local directory: {e}") - return 1 - else: - # Clone remote repository - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - progress.add_task(description="Cloning repository...", total=None) - - try: - repo_path = git_ops.clone_repository(repo) - except Exception as e: - console.print(f"[red]Error:[/red] Failed to clone repository: {e}") - return 1 - - try: - # Parse repository - parser = RepositoryParser(repo_path) - repository = parser.parse() - repository.url = normalize_repo_url(repo) - - # Get instructions to install - if bundle: - instructions = parser.get_instructions_for_bundle(name) - console.print(f"Installing bundle '[cyan]{name}[/cyan]' " f"with {len(instructions)} instruction(s)...") - else: - instruction = parser.get_instruction_by_name(name) - if not instruction: - console.print(f"[red]Error:[/red] Instruction '{name}' not found in repository") - return 1 - instructions = [instruction] - - # Install instructions - tracker = InstallationTracker() - resolver = ConflictResolver(default_strategy=strategy) - checksum_validator = ChecksumValidator() - - installed_count = 0 - skipped_count = 0 - - for instruction in instructions: - # Check if already exists - target_path = ai_tool.get_instruction_path(instruction.name, install_scope, project_root) - - if target_path.exists(): - # Handle conflict - if strategy == ConflictResolution.SKIP: - console.print(f" [yellow]Skipped:[/yellow] {instruction.name} (already exists)") - skipped_count += 1 - continue - elif strategy == ConflictResolution.RENAME: - conflict_info = resolver.resolve(instruction.name, target_path, strategy) - if conflict_info.new_path is None: - console.print(f" [red]Error:[/red] Failed to rename {instruction.name}") - continue - target_path = Path(conflict_info.new_path) - console.print(f" [yellow]Renamed:[/yellow] {instruction.name} -> " f"{target_path.name}") - elif strategy == ConflictResolution.OVERWRITE: - console.print(f" [yellow]Overwriting:[/yellow] {instruction.name}") - - # Validate checksum - try: - checksum_validator.validate(instruction.content, instruction.checksum) - except Exception as e: - console.print(f" [red]Error:[/red] {instruction.name}: {e}") - continue - - # Install instruction - try: - # Write file - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(instruction.content, encoding="utf-8") - - # Track installation - record = InstallationRecord( - instruction_name=instruction.name, - ai_tool=ai_tool.tool_type, - source_repo=repository.url, - installed_path=str(target_path), - installed_at=datetime.now(), - checksum=instruction.checksum, - bundle_name=name if bundle else None, - scope=install_scope, - ) - tracker.add_installation(record, project_root) - - console.print(f" [green]✓[/green] Installed: {instruction.name}") - installed_count += 1 - - except Exception as e: - console.print(f" [red]Error:[/red] {instruction.name}: {e}") - - # Summary - console.print() - console.print(f"[green]Successfully installed {installed_count} instruction(s)[/green]") - if skipped_count > 0: - console.print(f"[yellow]Skipped {skipped_count} existing instruction(s)[/yellow]") - - return 0 - - finally: - # Clean up cloned repository (but not local directories) - GitOperations.cleanup_repository(repo_path, is_temp=not is_local) - - -def _get_ai_tool(tool_name: Optional[str]) -> Optional[AITool]: - """ - Get AI tool instance from name. - - Args: - tool_name: Name of AI tool (or None to auto-detect) - - Returns: - AITool instance or None if not found - """ - detector = get_detector() - - if tool_name: - # Use specified tool - tool = detector.get_tool_by_name(tool_name) - if tool and not tool.is_installed(): - console.print(f"[yellow]Warning:[/yellow] {tool.tool_name} is not installed") - return None - return tool - - # Auto-detect: find first installed tool - return detector.get_primary_tool() diff --git a/devsync/cli/install_new.py b/devsync/cli/install_new.py deleted file mode 100644 index e5aee20..0000000 --- a/devsync/cli/install_new.py +++ /dev/null @@ -1,937 +0,0 @@ -"""Refactored install command with library support.""" - -from datetime import datetime -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.prompt import Confirm - -from devsync.ai_tools.base import AITool -from devsync.ai_tools.detector import AIToolDetector, get_detector -from devsync.core.conflict_resolution import ConflictResolver, prompt_conflict_resolution -from devsync.core.models import ( - ConflictResolution, - InstallationRecord, - InstallationScope, - LibraryInstruction, - RefType, -) -from devsync.storage.library import LibraryManager -from devsync.storage.tracker import InstallationTracker -from devsync.tui.installer import show_installer_tui -from devsync.utils.project import find_project_root -from devsync.utils.ui import print_error, print_info, print_success - -console = Console() - - -# ============================================================================ -# Helper Functions - Shared Installation Logic -# ============================================================================ - - -def _extract_ref_from_namespace(namespace: str) -> tuple[Optional[str], Optional[RefType]]: - """Extract Git reference from versioned namespace. - - Args: - namespace: Repository namespace (e.g., 'github.com_owner_repo@v1.0.0') - - Returns: - Tuple of (ref, ref_type) or (None, None) if no version info - """ - if "@" not in namespace: - return (None, None) - - # Split at @ to get the ref part - ref = namespace.split("@", 1)[1] - - # Try to determine ref type from the ref format - # This is a best-effort detection since we don't have the original ref_type stored - import re - - # Tags typically start with 'v' followed by numbers - if re.match(r"^v?\d+\.\d+", ref): - return (ref, RefType.TAG) - # Commit hashes are hex strings - elif re.match(r"^[0-9a-f]{7,40}$", ref): - return (ref, RefType.COMMIT) - # Everything else is likely a branch - else: - # Restore slashes that were converted to underscores - # This is approximate - feature_new might have been feature/new - return (ref, RefType.BRANCH) - - -def _parse_conflict_strategy(conflict_strategy: str) -> Optional[ConflictResolution]: - """Parse and validate conflict resolution strategy. - - Args: - conflict_strategy: Strategy string (prompt, skip, rename, overwrite) - - Returns: - ConflictResolution enum or None if invalid - """ - try: - return ConflictResolution(conflict_strategy.lower()) - except ValueError: - print_error( - f"Invalid conflict strategy: {conflict_strategy}. " "Must be 'prompt', 'skip', 'rename', or 'overwrite'." - ) - return None - - -def _get_project_root_for_installation() -> Optional[Path]: - """Detect and validate project root for installation. - - Returns: - Project root path or None if not found - """ - project_root = find_project_root() - if not project_root: - print_error("Could not detect project root. " "Make sure you're running from within a project directory.") - return None - console.print(f"Detected project root: [cyan]{project_root}[/cyan]") - return project_root - - -def _load_instructions_from_library(instruction_ids: list[str], library: LibraryManager) -> Optional[list]: - """Load instructions from library by IDs. - - Args: - instruction_ids: List of instruction IDs to load - library: Library manager instance - - Returns: - List of LibraryInstruction objects or None if any not found - """ - instructions = [] - for inst_id in instruction_ids: - inst = library.get_instruction(inst_id) - if not inst: - print_error(f"Instruction not found in library: {inst_id}") - return None - instructions.append(inst) - return instructions - - -def _detect_installed_collisions( - instructions: list[LibraryInstruction], - ai_tools: list[AITool], - install_names: dict[str, str], - project_root: Optional[Path], -) -> dict[str, list[InstallationRecord]]: - """Detect collisions with already-installed instructions. - - Args: - instructions: List of instructions to install - ai_tools: List of AI tools to install to - install_names: Mapping of instruction IDs to install names - project_root: Project root path - - Returns: - Dictionary mapping instruction_id to list of existing installations with same name - """ - tracker = InstallationTracker() - collisions = {} - - for inst in instructions: - install_name = install_names[inst.id] - - # Check if this name is already used in any tool - existing = tracker.find_instructions_by_name(install_name, project_root) - - # Filter to only collisions from different repositories - different_repo_collisions = [e for e in existing if e.source_repo != inst.repo_url] - - if different_repo_collisions: - collisions[inst.id] = different_repo_collisions - - return collisions - - -def _prompt_for_custom_filename( - instruction: LibraryInstruction, - existing_installations: list[InstallationRecord], - current_name: str, -) -> Optional[str]: - """Prompt user to provide custom filename for collision resolution. - - Args: - instruction: Instruction being installed - existing_installations: List of existing installations with same name - current_name: Current proposed install name - - Returns: - Custom filename or None to skip installation - """ - console.print(f"\n[yellow]⚠️ Name Collision:[/yellow] '{current_name}' is already installed") - console.print("\n[bold]Existing installations:[/bold]") - for existing in existing_installations: - repo_display = existing.source_repo or "unknown" - ref_display = existing.source_ref or "unknown" - console.print(f" • {existing.ai_tool.value}: {repo_display} @ {ref_display}") - - console.print("\n[bold]New installation:[/bold]") - console.print(f" • Repository: {instruction.repo_url}") - console.print(f" • Namespace: {instruction.repo_namespace}") - - console.print("\n[bold]Options:[/bold]") - console.print(" [1] Provide custom filename") - console.print(" [2] Skip this instruction") - - choice = typer.prompt("Select (1-2)", default="2") - - if choice == "1": - custom_name = typer.prompt( - "Enter custom filename (without extension)", - default=f"{instruction.repo_namespace.replace('@', '-').replace('/', '-')}_{instruction.name}", - ) - return str(custom_name) - else: - return None - - -def _resolve_name_conflicts(instructions: list[LibraryInstruction]) -> Optional[dict[str, str]]: - """Resolve naming conflicts for instructions with duplicate names. - - Args: - instructions: List of LibraryInstruction objects - - Returns: - Dict mapping instruction ID to install name, or None if cancelled - """ - # Check for name conflicts - name_conflicts: dict[str, list[LibraryInstruction]] = {} - for inst in instructions: - if inst.name not in name_conflicts: - name_conflicts[inst.name] = [] - name_conflicts[inst.name].append(inst) - - # Handle conflicts - install_names = {} # Map instruction ID to final install name - for name, insts in name_conflicts.items(): - if len(insts) > 1: - console.print(f"\n[yellow]⚠️ Name Conflict:[/yellow] " f"{len(insts)} instructions named '{name}'") - console.print("\nHow should they be installed?") - console.print(" [1] Namespace by repository (recommended)") - for inst in insts: - console.print(f" → {inst.repo_namespace}/{name}") - console.print(" [2] Skip installation (cancel)") - - choice = typer.prompt("Select (1-2)", default="1") - - if choice == "1": - # Use namespaced names - for inst in insts: - install_names[inst.id] = f"{inst.repo_namespace}/{name}" - else: - print_info("Installation cancelled") - return None - else: - # No conflict, use simple name - install_names[insts[0].id] = name - - return install_names - - -def _get_ai_tools_from_names(tool_names: list[str], detector: AIToolDetector) -> Optional[list[AITool]]: - """Get AI tool instances from tool names. - - Args: - tool_names: List of tool name strings - detector: AI tool detector instance - - Returns: - List of AITool instances or None if any invalid/not installed - """ - ai_tools = [] - for tool_name in tool_names: - tool = detector.get_tool_by_name(tool_name) - if not tool: - print_error(f"AI tool not found: {tool_name}") - return None - if not tool.is_installed(): - print_error(f"{tool.tool_name} is not installed") - return None - ai_tools.append(tool) - return ai_tools - - -def _show_installation_preview( - project_root: Path, instructions: list, ai_tools: list, install_names: dict[str, str] -) -> bool: - """Show installation preview and ask for confirmation. - - Args: - project_root: Project root path - instructions: List of instructions to install - ai_tools: List of AI tools to install to - install_names: Mapping of instruction IDs to install names - - Returns: - True if user confirms, False otherwise - """ - console.print("\n[bold cyan]📦 Installation Preview[/bold cyan]") - console.print(f"\n[bold]Project:[/bold] {project_root}") - console.print(f"[bold]Instructions:[/bold] {len(instructions)} selected") - console.print(f"[bold]Target tools:[/bold] {', '.join([t.tool_name for t in ai_tools])}\n") - - # Show where files will be created - console.print("[bold yellow]The following files will be created:[/bold yellow]\n") - - for ai_tool in ai_tools: - tool_dir = ai_tool.get_project_instructions_directory(project_root) - console.print(f"[cyan]{ai_tool.tool_name}[/cyan] → {tool_dir}") - for inst in instructions: - install_name = install_names[inst.id] - filename = f"{install_name}{ai_tool.get_instruction_file_extension()}" - console.print(f" • {filename}") - console.print() - - # Ask for confirmation - return Confirm.ask("\n[bold]Proceed with installation?[/bold]", default=True) - - -def _check_for_upgrades( - instructions: list, - ai_tools: list, - install_names: dict[str, str], - project_root: Optional[Path], -) -> dict[str, tuple[InstallationRecord, LibraryInstruction]]: - """Check if any instructions being installed are upgrades of existing installations. - - Args: - instructions: List of instructions to install - ai_tools: List of AI tools to install to - install_names: Mapping of instruction IDs to install names - project_root: Project root path - - Returns: - Dictionary mapping instruction_id to (existing_record, new_instruction) for upgrades - """ - tracker = InstallationTracker() - upgrades = {} - - for ai_tool in ai_tools: - for inst in instructions: - install_name = install_names[inst.id] - - # Check if this instruction is already installed for this tool - existing = tracker.get_installation(install_name, ai_tool.tool_type, project_root) - - if existing: - # Extract ref from both old and new - old_ref = existing.source_ref - new_ref, new_ref_type = _extract_ref_from_namespace(inst.repo_namespace) - - # Check if versions differ (potential upgrade) - if old_ref and new_ref and old_ref != new_ref: - key = f"{inst.id}_{ai_tool.tool_type.value}" - upgrades[key] = (existing, inst) - - return upgrades - - -def _prompt_for_upgrade( - existing: InstallationRecord, - new_instruction: LibraryInstruction, -) -> bool: - """Prompt user to confirm upgrade from one version to another. - - Args: - existing: Existing installation record - new_instruction: New instruction being installed - - Returns: - True if user confirms upgrade, False otherwise - """ - old_ref = existing.source_ref or "unknown" - new_ref, _ = _extract_ref_from_namespace(new_instruction.repo_namespace) - new_ref = new_ref or "unknown" - - console.print(f"\n[yellow]⚠️ Upgrade Detected:[/yellow] {existing.instruction_name}") - console.print(f" Current version: [cyan]{old_ref}[/cyan]") - console.print(f" New version: [green]{new_ref}[/green]") - console.print(f" Tool: {existing.ai_tool.value}") - - return Confirm.ask("\n[bold]Upgrade to new version?[/bold]", default=True) - - -def _perform_installation( - instructions: list, - ai_tools: list, - install_names: dict[str, str], - install_scope: InstallationScope, - project_root: Optional[Path], - strategy: ConflictResolution, -) -> tuple[int, int]: - """Perform the actual installation of instructions to tools. - - Args: - instructions: List of instructions to install - ai_tools: List of AI tools to install to - install_names: Mapping of instruction IDs to install names - install_scope: Installation scope (project/global) - project_root: Project root path - strategy: Conflict resolution strategy - - Returns: - Tuple of (installed_count, skipped_count) - """ - tracker = InstallationTracker() - resolver = ConflictResolver(default_strategy=strategy) - - installed_count = 0 - skipped_count = 0 - - for ai_tool in ai_tools: - console.print(f"\nInstalling to [cyan]{ai_tool.tool_name}[/cyan]...") - - for inst in instructions: - install_name = install_names[inst.id] - - # Get target path - target_path = ai_tool.get_instruction_path(install_name, install_scope, project_root) - - # Handle existing files - if target_path.exists(): - # Determine the actual strategy to use - actual_strategy = strategy - - # If strategy is PROMPT, ask user interactively - if strategy == ConflictResolution.PROMPT: - actual_strategy = prompt_conflict_resolution(install_name) - - if actual_strategy == ConflictResolution.SKIP: - console.print(f" [yellow]Skipped:[/yellow] {install_name} (already exists)") - skipped_count += 1 - continue - elif actual_strategy == ConflictResolution.RENAME: - conflict_info = resolver.resolve(install_name, target_path, actual_strategy) - if conflict_info.new_path is None: - console.print(f" [red]Error:[/red] Failed to rename {install_name}") - continue - target_path = Path(conflict_info.new_path) - console.print(f" [yellow]Renamed:[/yellow] {install_name} -> {target_path.name}") - elif actual_strategy == ConflictResolution.OVERWRITE: - console.print(f" [yellow]Overwriting:[/yellow] {install_name}") - - # Copy file from library - try: - target_path.parent.mkdir(parents=True, exist_ok=True) - - # Read from library - source_path = Path(inst.file_path) - content = source_path.read_text(encoding="utf-8") - - # Write to target - target_path.write_text(content, encoding="utf-8") - - # Extract ref information from namespace - source_ref, source_ref_type = _extract_ref_from_namespace(inst.repo_namespace) - - # Track installation - record = InstallationRecord( - instruction_name=install_name, - ai_tool=ai_tool.tool_type, - source_repo=inst.repo_url, - installed_path=str(target_path), - installed_at=datetime.now(), - checksum=inst.checksum, - bundle_name=None, - scope=install_scope, - source_ref=source_ref, - source_ref_type=source_ref_type, - ) - tracker.add_installation(record, project_root) - - console.print(f" [green]✓[/green] Installed: {install_name}") - installed_count += 1 - - except Exception as e: - print_error(f"Failed to install {install_name}: {e}") - - return installed_count, skipped_count - - -# ============================================================================ -# Public Installation Functions -# ============================================================================ - - -def install_from_library_tui( - tool: Optional[str] = None, -) -> int: - """ - Show TUI to select and install instructions from library. - - Args: - tool: AI tool to install to (None = auto-detect) - - Returns: - Exit code - """ - library = LibraryManager() - - # Check if library is empty - if not library.list_instructions(): - print_info("Library is empty. Use 'devsync download --repo ' to add instructions.") - return 1 - - # Show TUI (always installs to project level) - result = show_installer_tui(library=library, tool=tool) - - if not result: - console.print("[dim]Cancelled[/dim]") - return 0 - - # Install selected instructions - selected_instructions = result["instructions"] - selected_tools = result["tools"] # Now a list of tool names - - return install_from_library_direct_multi_tool( - instruction_ids=[inst.id for inst in selected_instructions], - tools=selected_tools, - conflict_strategy="skip", - ) - - -def install_from_library_direct_multi_tool( - instruction_ids: list[str], - tools: list[str], - conflict_strategy: str = "skip", -) -> int: - """ - Install specific instructions from library by ID to multiple tools. - - Args: - instruction_ids: List of instruction IDs to install - tools: List of AI tool names to install to - conflict_strategy: Conflict resolution strategy - - Returns: - Exit code - """ - library = LibraryManager() - install_scope = InstallationScope.PROJECT - - # Parse conflict strategy - strategy = _parse_conflict_strategy(conflict_strategy) - if strategy is None: - return 1 - - # Get project root - project_root = _get_project_root_for_installation() - if project_root is None: - return 1 - - # Load instructions from library - instructions = _load_instructions_from_library(instruction_ids, library) - if instructions is None: - return 1 - - # Resolve name conflicts - install_names = _resolve_name_conflicts(instructions) - if install_names is None: - return 0 - - # Get AI tools - detector = get_detector() - ai_tools = _get_ai_tools_from_names(tools, detector) - if ai_tools is None: - return 1 - - # Detect collisions with installed instructions from different repositories - collisions = _detect_installed_collisions(instructions, ai_tools, install_names, project_root) - if collisions: - console.print("\n[bold cyan]Handling Name Collisions[/bold cyan]") - for inst in instructions: - if inst.id in collisions: - current_name = install_names[inst.id] - custom_name = _prompt_for_custom_filename(inst, collisions[inst.id], current_name) - if custom_name is None: - # User chose to skip - console.print(f"[dim]Skipping {current_name}[/dim]") - # Remove from installation list - instructions = [i for i in instructions if i.id != inst.id] - else: - # Use custom name - install_names[inst.id] = custom_name - - if not instructions: - console.print("[yellow]No instructions remaining to install[/yellow]") - return 0 - - # Show preview and confirm - if not _show_installation_preview(project_root, instructions, ai_tools, install_names): - console.print("[dim]Installation cancelled[/dim]") - return 0 - - # Check for upgrades and prompt if needed - upgrades = _check_for_upgrades(instructions, ai_tools, install_names, project_root) - if upgrades: - console.print("\n[bold cyan]Upgrade Confirmation[/bold cyan]") - for key, (existing, new_inst) in upgrades.items(): - if not _prompt_for_upgrade(existing, new_inst): - console.print("[dim]Installation cancelled[/dim]") - return 0 - - # Perform installation - installed_count, skipped_count = _perform_installation( - instructions, ai_tools, install_names, install_scope, project_root, strategy - ) - - # Summary - console.print() - if installed_count > 0: - print_success(f"✓ Successfully installed {installed_count} instruction(s)") - if skipped_count > 0: - print_info(f"Skipped {skipped_count} existing instruction(s)") - - return 0 - - -def install_from_library_direct( - instruction_ids: list[str], - tool: Optional[str] = None, - conflict_strategy: str = "skip", -) -> int: - """ - Install specific instructions from library by ID. - - Args: - instruction_ids: List of instruction IDs to install - tool: AI tool to install to - conflict_strategy: Conflict resolution strategy - - Returns: - Exit code - """ - library = LibraryManager() - install_scope = InstallationScope.PROJECT - - # Parse conflict strategy - strategy = _parse_conflict_strategy(conflict_strategy) - if strategy is None: - return 1 - - # Get project root - project_root = _get_project_root_for_installation() - if project_root is None: - return 1 - - # Load instructions from library - instructions = _load_instructions_from_library(instruction_ids, library) - if instructions is None: - return 1 - - # Resolve name conflicts - install_names = _resolve_name_conflicts(instructions) - if install_names is None: - return 0 - - # Determine AI tool(s) - detector = get_detector() - if tool: - ai_tools = _get_ai_tools_from_names([tool], detector) - if ai_tools is None: - return 1 - else: - ai_tools = detector.detect_installed_tools() - if not ai_tools: - print_error("No AI coding tools detected") - return 1 - - # Detect collisions with installed instructions from different repositories - collisions = _detect_installed_collisions(instructions, ai_tools, install_names, project_root) - if collisions: - console.print("\n[bold cyan]Handling Name Collisions[/bold cyan]") - for inst in instructions: - if inst.id in collisions: - current_name = install_names[inst.id] - custom_name = _prompt_for_custom_filename(inst, collisions[inst.id], current_name) - if custom_name is None: - # User chose to skip - console.print(f"[dim]Skipping {current_name}[/dim]") - # Remove from installation list - instructions = [i for i in instructions if i.id != inst.id] - else: - # Use custom name - install_names[inst.id] = custom_name - - if not instructions: - console.print("[yellow]No instructions remaining to install[/yellow]") - return 0 - - # Show preview and confirm - if not _show_installation_preview(project_root, instructions, ai_tools, install_names): - console.print("[dim]Installation cancelled[/dim]") - return 0 - - # Check for upgrades and prompt if needed - upgrades = _check_for_upgrades(instructions, ai_tools, install_names, project_root) - if upgrades: - console.print("\n[bold cyan]Upgrade Confirmation[/bold cyan]") - for key, (existing, new_inst) in upgrades.items(): - if not _prompt_for_upgrade(existing, new_inst): - console.print("[dim]Installation cancelled[/dim]") - return 0 - - # Perform installation - installed_count, skipped_count = _perform_installation( - instructions, ai_tools, install_names, install_scope, project_root, strategy - ) - - # Summary - console.print() - if installed_count > 0: - print_success(f"✓ Successfully installed {installed_count} instruction(s)") - if skipped_count > 0: - print_info(f"Skipped {skipped_count} existing instruction(s)") - - return 0 - - -def install_from_library_by_name( - name: str, - tool: Optional[str] = None, - conflict_strategy: str = "skip", -) -> int: - """ - Install instruction(s) from library by name. - - Supports source/name format for disambiguation (e.g., 'company/python-best-practices'). - - Args: - name: Instruction name (or source/name format) - tool: AI tool to install to - conflict_strategy: Conflict resolution strategy - - Returns: - Exit code - """ - library = LibraryManager() - - # Parse source/name format - source_alias = None - instruction_name = name - if "/" in name: - parts = name.split("/", 1) - source_alias = parts[0] - instruction_name = parts[1] - - # Find instructions with this name - if source_alias: - # Filter by source alias - instructions = library.get_instructions_by_source_and_name(source_alias, instruction_name) - else: - instructions = library.get_instructions_by_name(instruction_name) - - if not instructions: - print_error(f"No instruction named '{name}' found in library.") - print_info("Use 'devsync list library --instructions' to see available instructions.") - return 1 - - if len(instructions) == 1: - # Single match, install it - return install_from_library_direct( - instruction_ids=[instructions[0].id], - tool=tool, - conflict_strategy=conflict_strategy, - ) - - # Multiple matches - show options - console.print(f"\n[yellow]Multiple instructions named '{name}' found:[/yellow]\n") - for i, inst in enumerate(instructions, 1): - # Extract ref from namespace for display - ref, ref_type = _extract_ref_from_namespace(inst.repo_namespace) - ref_display = f"@{ref}" if ref else f"v{inst.version}" - console.print(f" [{i}] {inst.repo_name} ({ref_display}) - {inst.author}") - console.print(f" {inst.description}") - console.print() - - console.print(" [A] Install all") - console.print(" [C] Cancel") - console.print() - - choice = typer.prompt("Select", default="C") - - if choice.upper() == "C": - print_info("Installation cancelled") - return 0 - elif choice.upper() == "A": - # Install all - return install_from_library_direct( - instruction_ids=[inst.id for inst in instructions], - tool=tool, - conflict_strategy=conflict_strategy, - ) - elif choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(instructions): - return install_from_library_direct( - instruction_ids=[instructions[idx].id], - tool=tool, - conflict_strategy=conflict_strategy, - ) - - print_error("Invalid selection") - return 1 - - -# Keep the original direct install function for backward compatibility -def install_from_repo_direct( - name: str, - repo: str, - tool: Optional[str] = None, - conflict_strategy: str = "skip", - bundle: bool = False, -) -> int: - """ - Install directly from a repository (backward compatibility). - - This is the original install function, preserved for --repo usage. - """ - # Import the original function - from devsync.cli.install import install_instruction as original_install - - return original_install( - name=name, - repo=repo, - tool=tool, - conflict_strategy=conflict_strategy, - bundle=bundle, - ) - - -def install_multiple_from_library( - names: list[str], - tools: Optional[list[str]], - conflict_strategy: str, -) -> int: - """ - Install multiple instructions from library. - - Args: - names: List of instruction names - tools: List of AI tool names (None = all detected tools) - conflict_strategy: Conflict resolution strategy - - Returns: - Exit code - """ - library = LibraryManager() - - # Get all instructions by name - all_instructions = [] - for name in names: - insts = library.get_instructions_by_name(name) - if not insts: - print_error(f"No instruction named '{name}' found in library.") - return 1 - - if len(insts) > 1: - # Multiple matches - show options - console.print(f"\n[yellow]Multiple instructions named '{name}' found:[/yellow]\n") - for i, inst in enumerate(insts, 1): - console.print(f" [{i}] {inst.repo_name} (v{inst.version}) - {inst.author}") - console.print() - - choice = typer.prompt(f"Select which '{name}' to install (1-{len(insts)})", default="1") - - if choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(insts): - all_instructions.append(insts[idx]) - else: - print_error("Invalid selection") - return 1 - else: - print_error("Invalid selection") - return 1 - else: - all_instructions.append(insts[0]) - - # Get instruction IDs - instruction_ids = [inst.id for inst in all_instructions] - - # Install using the multi-tool function - if tools: - return install_from_library_direct_multi_tool( - instruction_ids=instruction_ids, - tools=tools, - conflict_strategy=conflict_strategy, - ) - else: - # Use existing single-tool logic (all tools) - return install_from_library_direct( - instruction_ids=instruction_ids, - tool=None, # Will install to all detected tools - conflict_strategy=conflict_strategy, - ) - - -def install_instruction_unified( - names: Optional[list[str]] = None, - repo: Optional[str] = None, - tools: Optional[list[str]] = None, - conflict_strategy: str = "prompt", - bundle: bool = False, -) -> int: - """ - Unified install function that routes to appropriate implementation. - - All installations are at project level. - - Args: - names: Instruction name(s) (optional, can be multiple) - repo: Repository URL (optional) - tools: AI tool(s) to install to (optional, can be multiple) - conflict_strategy: Conflict resolution strategy - bundle: Whether installing a bundle - - Returns: - Exit code - """ - # Convert single tool format (backward compat) - tool = tools[0] if tools and len(tools) == 1 else None - - # Case 1: Direct install from repo (backward compat) - if repo: - if not names or len(names) == 0: - print_error("When using --repo, you must specify an instruction name") - return 1 - - # Only support single name with --repo for now - if len(names) > 1: - print_error("Cannot install multiple instructions with --repo. Install one at a time or use the library.") - return 1 - - return install_from_repo_direct( - name=names[0], - repo=repo, - tool=tool, - conflict_strategy=conflict_strategy, - bundle=bundle, - ) - - # Case 2: Install from library with TUI - if not names or len(names) == 0: - return install_from_library_tui(tool=tool) - - # Case 3: Install multiple instructions from library - if len(names) > 1 or (tools and len(tools) > 1): - return install_multiple_from_library( - names=names, - tools=tools, - conflict_strategy=conflict_strategy, - ) - - # Case 4: Install single instruction from library by name - return install_from_library_by_name( - name=names[0], - tool=tool, - conflict_strategy=conflict_strategy, - ) diff --git a/devsync/cli/list.py b/devsync/cli/list.py deleted file mode 100644 index 8d6dc9d..0000000 --- a/devsync/cli/list.py +++ /dev/null @@ -1,275 +0,0 @@ -"""List command implementation.""" - -from typing import Optional - -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.table import Table - -from devsync.core.git_operations import GitOperations -from devsync.core.models import AIToolType -from devsync.core.repository import RepositoryParser -from devsync.storage.library import LibraryManager -from devsync.storage.tracker import InstallationTracker -from devsync.utils.project import find_project_root -from devsync.utils.ui import ( - format_installed_table, - format_instructions_table, - print_error, - print_info, -) -from devsync.utils.validation import is_valid_git_url, normalize_repo_url - -console = Console() - - -def list_available( - repo: str, - tag: Optional[str] = None, - bundles_only: bool = False, - instructions_only: bool = False, -) -> int: - """ - List available instructions from a repository. - - Args: - repo: Git repository URL - tag: Filter by tag - bundles_only: Show only bundles - instructions_only: Show only instructions - - Returns: - Exit code (0 for success, 1 for error) - """ - # Validate repository URL - if not is_valid_git_url(repo): - print_error(f"Invalid Git repository URL: {repo}", console) - return 1 - - # Check Git is installed - if not GitOperations.is_git_installed(): - print_error("Git is not installed. Please install Git and try again.", console) - return 1 - - # Clone repository or use local path - git_ops = GitOperations() - is_local = git_ops.is_local_path(repo) - - if is_local: - # Use local directory directly - try: - repo_path = git_ops.clone_repository(repo) - except Exception as e: - print_error(f"Failed to access local directory: {e}", console) - return 1 - else: - # Clone remote repository - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - progress.add_task(description="Fetching repository...", total=None) - - try: - repo_path = git_ops.clone_repository(repo) - except Exception as e: - print_error(f"Failed to clone repository: {e}", console) - return 1 - - try: - # Parse repository - parser = RepositoryParser(repo_path) - repository = parser.parse() - - # Filter by tag if specified - instructions = repository.instructions - bundles = repository.bundles - - if tag: - instructions = [i for i in instructions if tag in i.tags] - bundles = [b for b in bundles if tag in b.tags] - print_info(f"Filtered by tag: {tag}", console) - - # Apply filters - if bundles_only: - instructions = [] - if instructions_only: - bundles = [] - - # Check if anything to display - if not instructions and not bundles: - if tag: - console.print(f"[yellow]No instructions or bundles found with tag '{tag}'[/yellow]") - else: - console.print("[yellow]No instructions or bundles found in repository[/yellow]") - return 0 - - # Display table - table = format_instructions_table(instructions, bundles, show_bundles=not instructions_only) - console.print() - console.print(table) - console.print() - - # Summary - len(instructions) + len(bundles) - console.print(f"Found {len(instructions)} instruction(s) and {len(bundles)} bundle(s)") - - return 0 - - finally: - # Clean up cloned repository (but not local directories) - GitOperations.cleanup_repository(repo_path, is_temp=not is_local) - - -def list_installed( - tool: Optional[str] = None, - repo: Optional[str] = None, -) -> int: - """ - List installed instructions. - - Args: - tool: Filter by AI tool (cursor, copilot, etc.) - repo: Filter by source repository URL - - Returns: - Exit code (0 for success, 1 for error) - """ - tracker = InstallationTracker() - - # Detect project root to include project-scoped installations - project_root = find_project_root() - - # Get all installed instructions (both global and project) - if tool: - # Filter by specific tool - try: - ai_tool = AIToolType(tool.lower()) - records = tracker.get_installed_instructions(ai_tool, project_root=project_root) - except ValueError: - print_error(f"Invalid AI tool: {tool}. " f"Valid options: cursor, copilot, windsurf, claude", console) - return 1 - else: - records = tracker.get_installed_instructions(project_root=project_root) - - # Filter by repository if specified - if repo: - normalized_repo = normalize_repo_url(repo) - records = [r for r in records if normalize_repo_url(r.source_repo) == normalized_repo] - - # Check if anything to display - if not records: - if tool and repo: - console.print(f"[yellow]No instructions installed for {tool} from {repo}[/yellow]") - elif tool: - console.print(f"[yellow]No instructions installed for {tool}[/yellow]") - elif repo: - console.print(f"[yellow]No instructions installed from {repo}[/yellow]") - else: - console.print("[yellow]No instructions installed[/yellow]") - return 0 - - # Display table - table = format_installed_table(records, group_by_tool=not bool(tool)) - console.print() - console.print(table) - console.print() - - # Summary - console.print(f"Total: {len(records)} installed instruction(s)") - - return 0 - - -def list_library( - repo_filter: Optional[str] = None, - show_instructions: bool = False, -) -> int: - """ - List sources and instructions in the local library. - - Args: - repo_filter: Filter by source alias or namespace - show_instructions: Show individual instructions - - Returns: - Exit code (0 for success) - """ - library = LibraryManager() - repositories = library.list_repositories() - - if not repositories: - print_info("Library is empty. Use 'devsync download' to add sources.") - return 0 - - # Filter if specified (match against alias or namespace) - if repo_filter: - repositories = [ - r - for r in repositories - if repo_filter.lower() in (r.alias or "").lower() or repo_filter.lower() in r.namespace.lower() - ] - if not repositories: - print_error(f"No sources matching: {repo_filter}") - return 1 - - # Show sources - if not show_instructions: - table = Table(title="Library Sources", show_header=True, header_style="bold cyan") - table.add_column("Alias", style="cyan", no_wrap=True) - table.add_column("Name", style="green") - table.add_column("Version", style="yellow") - table.add_column("Instructions", style="magenta") - table.add_column("Downloaded", style="blue") - - for repo in sorted(repositories, key=lambda r: r.alias or r.name): - table.add_row( - repo.alias or repo.namespace, - repo.name, - repo.version, - str(len(repo.instructions)), - repo.downloaded_at.strftime("%Y-%m-%d"), - ) - - console.print() - console.print(table) - console.print() - console.print(f"Total: {len(repositories)} source(s) in library") - console.print() - console.print("[dim]Use 'devsync install' to install instructions from library[/dim]") - - # Show instructions - else: - table = Table(title="Library Instructions", show_header=True, header_style="bold cyan") - table.add_column("Name", style="cyan") - table.add_column("Description") - table.add_column("Repository", style="green") - table.add_column("Version", style="yellow") - table.add_column("Tags", style="magenta") - - all_instructions = [] - for repo in repositories: - all_instructions.extend(repo.instructions) - - for inst in sorted(all_instructions, key=lambda i: i.name): - tags_str = ", ".join(inst.tags[:3]) if inst.tags else "-" - if len(inst.tags) > 3: - tags_str += f" +{len(inst.tags) - 3}" - - table.add_row( - inst.name, - inst.description[:60] + "..." if len(inst.description) > 60 else inst.description, - inst.repo_name, - inst.version, - tags_str, - ) - - console.print() - console.print(table) - console.print() - console.print(f"Total: {len(all_instructions)} instruction(s) in library") - console.print() - console.print("[dim]Use 'devsync install' to install these instructions[/dim]") - - return 0 diff --git a/devsync/cli/mcp_configure.py b/devsync/cli/mcp_configure.py deleted file mode 100644 index 13a3247..0000000 --- a/devsync/cli/mcp_configure.py +++ /dev/null @@ -1,233 +0,0 @@ -"""CLI command for configuring MCP server credentials.""" - -import logging -from pathlib import Path - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.core.mcp.credentials import CredentialManager -from devsync.core.mcp.manager import MCPManager -from devsync.core.models import InstallationScope -from devsync.utils.paths import _resolve_data_dir - -logger = logging.getLogger(__name__) -console = Console() - - -def mcp_configure_command( - server_ref: str = typer.Argument(..., help="Server reference in format 'namespace.server' or just 'namespace'"), - scope: str = typer.Option("project", "--scope", help="Installation scope: project or global"), - non_interactive: bool = typer.Option(False, "--non-interactive", help="Read credentials from environment"), - show_current: bool = typer.Option(False, "--show-current", help="Show current credential values (masked)"), - json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), -) -> int: - """ - Configure credentials for MCP servers. - - This command helps you securely configure required environment variables for - MCP servers. Credentials are stored in .devsync/.env (gitignored). - - Examples: - - # Configure specific server interactively - devsync mcp configure backend.github - - # Configure all servers in a namespace - devsync mcp configure backend - - # Configure with non-interactive mode (read from env) - export GITHUB_TOKEN=ghp_xxxxx - devsync mcp configure backend.github --non-interactive - - # Show current credentials (masked) - devsync mcp configure backend.github --show-current - - # Configure globally (available in all projects) - devsync mcp configure backend.github --scope global - """ - try: - # Parse scope - try: - install_scope = InstallationScope(scope) - except ValueError: - console.print(f"[red]Error:[/red] Invalid scope '{scope}'. Must be 'project' or 'global'") - return 1 - - # Parse server reference - if "." in server_ref: - namespace, server_name = server_ref.rsplit(".", 1) - else: - namespace = server_ref - server_name = None - - # Get library root and managers - library_root = _get_library_root() - mcp_manager = MCPManager(library_root) - cred_manager = CredentialManager() - - # Load template - template = mcp_manager.load_template(namespace, scope=install_scope) - - if not template: - console.print(f"[red]Error:[/red] Template '{namespace}' not found in {install_scope.value} scope") - console.print(f"\nInstall it first: [cyan]devsync mcp install --as {namespace}[/cyan]") - return 1 - - # Get servers to configure - if server_name: - # Configure specific server - server = template.get_server_by_name(server_name) - if not server: - console.print(f"[red]Error:[/red] Server '{server_name}' not found in template '{namespace}'") - console.print(f"\nAvailable servers: {', '.join(s.name for s in template.servers)}") - return 1 - servers = [server] - else: - # Configure all servers in namespace - servers = template.servers - - if not servers: - console.print(f"[yellow]Warning:[/yellow] No MCP servers found in template '{namespace}'") - return 0 - - # Show current credentials if requested - if show_current: - return _show_current_credentials(servers, cred_manager, install_scope, json_output) - - # Configure each server - configured_count = 0 - skipped_count = 0 - - for server in servers: - required_vars = server.get_required_env_vars() - - if not required_vars: - console.print(f"[dim]Server '{server.name}' requires no credentials[/dim]") - skipped_count += 1 - continue - - try: - cred_manager.configure_server( - server, - scope=install_scope, - non_interactive=non_interactive, - ) - configured_count += 1 - - except ValueError as e: - console.print(f"[red]Error configuring '{server.name}':[/red] {e}") - return 1 - - # Output results - if json_output: - import json - - result = { - "success": True, - "namespace": namespace, - "configured": configured_count, - "skipped": skipped_count, - "total": len(servers), - "scope": install_scope.value, - } - console.print(json.dumps(result, indent=2)) - else: - console.print("\n[green]✓[/green] Credential configuration complete!") - console.print(f" Configured: {configured_count} server(s)") - if skipped_count > 0: - console.print(f" Skipped: {skipped_count} server(s) (no credentials needed)") - - # Show env file location - if install_scope == InstallationScope.GLOBAL: - env_path = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "global" / ".env" - else: - env_path = _resolve_data_dir(Path.cwd(), ".devsync", [".instructionkit", ".ai-config-kit"]) / ".env" - - console.print(f"\n[dim]Credentials saved to: {env_path}[/dim]") - console.print("[dim](This file is automatically gitignored)[/dim]") - - # Show next steps - console.print("\n[bold]Next step:[/bold]") - console.print(" Sync to AI tools: [cyan]devsync mcp sync --tool all[/cyan]") - - return 0 - - except Exception as e: - logger.exception("Unexpected error during credential configuration") - if json_output: - import json - - console.print(json.dumps({"success": False, "error": str(e)}, indent=2)) - else: - console.print(f"[red]Unexpected error:[/red] {e}") - return 1 - - -def _show_current_credentials( - servers: list, - cred_manager: CredentialManager, - scope: InstallationScope, - json_output: bool, -) -> int: - """Show current credential values for servers.""" - if json_output: - import json - from typing import Any - - result: dict[str, Any] = { - "scope": scope.value, - "servers": [], - } - - for server in servers: - credentials = cred_manager.show_current_credentials(server, scope) - is_valid, missing = cred_manager.validate_credentials(server, scope) - - result["servers"].append( - { - "name": server.get_fully_qualified_name(), - "credentials": credentials, - "is_configured": is_valid, - "missing": missing, - } - ) - - console.print(json.dumps(result, indent=2)) - else: - table = Table(title=f"Current Credentials ({scope.value} scope)") - table.add_column("Server", style="cyan") - table.add_column("Variable", style="yellow") - table.add_column("Value", style="dim") - table.add_column("Status", style="green") - - for server in servers: - credentials = cred_manager.show_current_credentials(server, scope) - is_valid, missing = cred_manager.validate_credentials(server, scope) - - for i, (var_name, masked_value) in enumerate(credentials.items()): - server_name = server.get_fully_qualified_name() if i == 0 else "" - status = "✓" if var_name not in missing else "⚠ Missing" - status_style = "green" if var_name not in missing else "yellow" - - table.add_row( - server_name, - var_name, - masked_value, - f"[{status_style}]{status}[/{status_style}]", - ) - - console.print(table) - - return 0 - - -def _get_library_root() -> Path: - """ - Get the library root directory. - - Returns: - Path to library root (~/.devsync/library/) - """ - return _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "library" diff --git a/devsync/cli/mcp_install.py b/devsync/cli/mcp_install.py deleted file mode 100644 index a78935d..0000000 --- a/devsync/cli/mcp_install.py +++ /dev/null @@ -1,167 +0,0 @@ -"""CLI command for installing MCP templates.""" - -import logging -from pathlib import Path - -import typer -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn - -from devsync.core.mcp.manager import MCPManager -from devsync.core.models import InstallationScope -from devsync.utils.paths import _resolve_data_dir - -logger = logging.getLogger(__name__) -console = Console() - - -def mcp_install_command( - source: str = typer.Argument(..., help="Source URL or local path to MCP template repository"), - namespace: str = typer.Option(..., "--as", help="Namespace for this template (unique identifier)"), - scope: str = typer.Option("project", "--scope", help="Installation scope: project or global"), - force: bool = typer.Option(False, "--force", help="Overwrite existing template if it exists"), - json_output: bool = typer.Option(False, "--json", help="Output results as JSON"), -) -> int: - """ - Install MCP server configurations from a template repository. - - This downloads and caches MCP server definitions from a Git repository or local - directory into your local library. After installation, use 'devsync mcp configure' - to set up credentials and 'devsync mcp sync' to apply to AI tools. - - Examples: - - # Install from GitHub repository - devsync mcp install https://github.com/company/backend-tools --as backend - - # Install from local directory - devsync mcp install ./my-mcp-configs --as local-tools - - # Install globally (available in all projects) - devsync mcp install https://github.com/me/personal-tools --as personal --scope global - - # Force overwrite existing template - devsync mcp install https://github.com/company/backend-tools --as backend --force - """ - try: - # Parse scope - try: - install_scope = InstallationScope(scope) - except ValueError: - console.print(f"[red]Error:[/red] Invalid scope '{scope}'. Must be 'project' or 'global'") - return 1 - - # Get library root - library_root = _get_library_root() - - # Create MCP manager - manager = MCPManager(library_root) - - # Install template with progress indicator - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - task = progress.add_task(f"Installing MCP template '{namespace}'...", total=None) - - try: - template = manager.install_template( - source=source, - namespace=namespace, - scope=install_scope, - force=force, - ) - finally: - progress.remove_task(task) - - # Output results - if json_output: - import json - - result = { - "success": True, - "namespace": template.namespace, - "version": template.version, - "servers": len(template.servers), - "sets": len(template.sets), - "scope": install_scope.value, - } - console.print(json.dumps(result, indent=2)) - else: - console.print(f"[green]✓[/green] Installed MCP template: [bold]{namespace}[/bold]") - console.print(f" Version: {template.version}") - console.print(f" Description: {template.description}") - console.print(f" Servers: {len(template.servers)}") - console.print(f" Sets: {len(template.sets)}") - console.print(f" Scope: {install_scope.value}") - - # Show next steps - console.print("\n[bold]Next steps:[/bold]") - - # Check which servers need configuration - servers_needing_config = [s for s in template.servers if s.get_required_env_vars()] - - if servers_needing_config: - console.print(" 1. Configure credentials for servers:") - for server in servers_needing_config[:3]: # Show first 3 - console.print(f" [cyan]devsync mcp configure {namespace}.{server.name}[/cyan]") - if len(servers_needing_config) > 3: - console.print(f" ... and {len(servers_needing_config) - 3} more") - else: - console.print(" 1. [dim]No credentials needed for servers[/dim]") - - console.print(" 2. Sync to AI tools: [cyan]devsync mcp sync --tool all[/cyan]") - - if template.sets: - set_name = template.sets[0].name - console.print(f" 3. Or activate a set: [cyan]devsync mcp activate {namespace}.{set_name}[/cyan]") - - return 0 - - except ValueError as e: - if json_output: - import json - - console.print(json.dumps({"success": False, "error": str(e)}, indent=2)) - else: - console.print(f"[red]Error:[/red] {e}") - return 1 - - except FileNotFoundError as e: - if json_output: - import json - - console.print(json.dumps({"success": False, "error": str(e)}, indent=2)) - else: - console.print(f"[red]Error:[/red] {e}") - return 1 - - except RuntimeError as e: - if json_output: - import json - - console.print(json.dumps({"success": False, "error": str(e)}, indent=2)) - else: - console.print(f"[red]Error:[/red] {e}") - return 1 - - except Exception as e: - logger.exception("Unexpected error during MCP template installation") - if json_output: - import json - - console.print(json.dumps({"success": False, "error": f"Unexpected error: {e}"}, indent=2)) - else: - console.print(f"[red]Unexpected error:[/red] {e}") - return 1 - - -def _get_library_root() -> Path: - """ - Get the library root directory. - - Returns: - Path to library root (~/.devsync/library/) - """ - return _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "library" diff --git a/devsync/cli/mcp_sync.py b/devsync/cli/mcp_sync.py deleted file mode 100644 index ce10764..0000000 --- a/devsync/cli/mcp_sync.py +++ /dev/null @@ -1,166 +0,0 @@ -"""CLI command for syncing MCP servers to AI tools.""" - -import logging -from pathlib import Path - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.ai_tools.mcp_syncer import MCPSyncer -from devsync.core.models import InstallationScope -from devsync.utils.paths import _resolve_data_dir - -logger = logging.getLogger(__name__) -console = Console() - - -def mcp_sync_command( - tool: str = typer.Option( - "all", - "--tool", - "-t", - help="AI tool to sync to (claude, cursor, windsurf, or all)", - ), - scope: str = typer.Option( - "project", - "--scope", - help="Scope to load configurations from (project or global)", - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be synced without actually syncing", - ), - no_backup: bool = typer.Option( - False, - "--no-backup", - help="Skip creating backup of config files before modifying", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Output results as JSON", - ), -) -> int: - """ - Sync configured MCP servers to AI tool configuration files. - - This command reads installed MCP servers from the library, resolves their - environment variables from .devsync/.env, and writes them to AI - tool configuration files (e.g., claude_desktop_config.json). - - Examples: - - # Sync to all detected AI tools - devsync mcp sync --tool all - - # Sync to specific tool - devsync mcp sync --tool claude - - # Dry run to see what would be synced - devsync mcp sync --tool all --dry-run - - # Sync without creating backups - devsync mcp sync --tool claude --no-backup - - # Sync global configurations - devsync mcp sync --scope global - """ - try: - # Parse scope - try: - install_scope = InstallationScope(scope) - except ValueError: - console.print(f"[red]Error:[/red] Invalid scope '{scope}'. Must be 'project' or 'global'") - return 1 - - # Create syncer - syncer = MCPSyncer( - library_root=_resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "library", - project_root=Path.cwd(), - ) - - # Parse tool names - tool_names = [tool] if tool != "all" else ["all"] - - # Show sync info - if not json_output: - console.print("\n[bold]Syncing MCP servers to AI tools[/bold]") - console.print(f"Scope: {install_scope.value}") - console.print(f"Tools: {tool}") - if dry_run: - console.print("[yellow]DRY RUN - no changes will be made[/yellow]") - console.print() - - # Perform sync - result = syncer.sync_all( - tool_names=tool_names, - scope=install_scope, - create_backup=not no_backup, - dry_run=dry_run, - ) - - # Output results - if json_output: - import json - - output = { - "success": result.success, - "synced_tools": result.synced_tools, - "skipped_tools": [{"name": name, "reason": reason} for name, reason in result.skipped_tools], - "synced_servers": result.synced_servers, - "skipped_servers": [{"name": name, "reason": reason} for name, reason in result.skipped_servers], - } - console.print(json.dumps(output, indent=2)) - else: - # Show synced tools - if result.synced_tools: - console.print(f"[green]✓[/green] Synced to {len(result.synced_tools)} tool(s):") - for tool_name in result.synced_tools: - console.print(f" • {tool_name}") - else: - console.print("[yellow]⚠[/yellow] No tools were synced") - - # Show skipped tools - if result.skipped_tools: - console.print(f"\n[yellow]⚠[/yellow] Skipped {len(result.skipped_tools)} tool(s):") - for tool_name, reason in result.skipped_tools: - console.print(f" • {tool_name}: {reason}") - - # Show server summary - console.print("\n[bold]Server Summary:[/bold]") - console.print(f" Synced: {len(result.synced_servers)} server(s)") - if result.skipped_servers: - console.print(f" Skipped: {len(result.skipped_servers)} server(s)") - - # Show skipped servers details - if result.skipped_servers: - console.print("\n[yellow]Skipped Servers:[/yellow]") - table = Table(show_header=True) - table.add_column("Server", style="cyan") - table.add_column("Reason", style="yellow") - - for server_name, reason in result.skipped_servers: - table.add_row(server_name, reason) - - console.print(table) - - console.print( - "\n[dim]Tip: Run 'devsync mcp configure ' to configure missing credentials[/dim]" - ) - - if not result.success: - return 1 - - return 0 - - except Exception as e: - logger.exception("Unexpected error during MCP sync") - if json_output: - import json - - console.print(json.dumps({"success": False, "error": str(e)}, indent=2)) - else: - console.print(f"[red]Unexpected error:[/red] {e}") - return 1 diff --git a/devsync/cli/package.py b/devsync/cli/package.py deleted file mode 100644 index de323af..0000000 --- a/devsync/cli/package.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Package CLI commands.""" - -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.cli.package_create import create_package_command -from devsync.cli.package_install import InstallationResult, install_package -from devsync.core.models import ( - AIToolType, - ConflictResolution, - InstallationScope, - InstallationStatus, -) -from devsync.storage.package_tracker import PackageTracker -from devsync.utils.paths import _resolve_data_dir -from devsync.utils.project import find_project_root - -console = Console() -package_app = typer.Typer(help="Manage configuration packages") - -# Register create command -package_app.command(name="create")(create_package_command) - - -@package_app.command(name="install") -def install_package_command( - package_path: str = typer.Argument( - ..., - help="Path to package directory containing manifest", - ), - target_ide: str = typer.Option( - "claude", - "--ide", - "-i", - help="Target IDE (claude, cursor, windsurf, copilot)", - ), - project: Optional[str] = typer.Option( - None, - "--project", - "-p", - help="Project root directory (defaults to current directory)", - ), - conflict: str = typer.Option( - "skip", - "--conflict", - "-c", - help="Conflict resolution strategy (skip, overwrite, rename)", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Force reinstallation even if already installed", - ), - quiet: bool = typer.Option( - False, - "--quiet", - "-q", - help="Minimal output", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Output results as JSON", - ), -) -> None: - """ - Install a configuration package to a project. - - Package must contain an ai-config-kit-package.yaml manifest. - - Example: - devsync package install ./python-dev-setup --ide claude - devsync package install ~/packages/my-package --ide cursor --conflict overwrite - """ - try: - # Parse target IDE - try: - ide_type = AIToolType(target_ide.lower()) - except ValueError: - console.print( - f"[red]Error: Invalid IDE '{target_ide}'. " f"Valid options: claude, cursor, windsurf, copilot[/red]" - ) - raise typer.Exit(1) - - # Parse conflict resolution - try: - conflict_resolution = ConflictResolution(conflict.lower()) - except ValueError: - console.print( - f"[red]Error: Invalid conflict resolution '{conflict}'. " - f"Valid options: skip, overwrite, rename[/red]" - ) - raise typer.Exit(1) - - # Determine project root - if project: - project_root = Path(project).resolve() - if not project_root.exists(): - console.print(f"[red]Error: Project directory not found: {project}[/red]") - raise typer.Exit(1) - else: - project_root_maybe = find_project_root() - if not project_root_maybe: - console.print("[red]Error: Could not find project root. " "Use --project to specify explicitly.[/red]") - raise typer.Exit(1) - project_root = project_root_maybe - - # Resolve package path - pkg_path = Path(package_path).resolve() - if not pkg_path.exists(): - console.print(f"[red]Error: Package directory not found: {package_path}[/red]") - raise typer.Exit(1) - - if not quiet: - console.print(f"[cyan]Installing package from {pkg_path}...[/cyan]") - console.print(f"[cyan]Target IDE: {ide_type.value}[/cyan]") - console.print(f"[cyan]Project root: {project_root}[/cyan]") - - # Install package - result = install_package( - package_path=pkg_path, - project_root=project_root, - target_ide=ide_type, - scope=InstallationScope.PROJECT, - conflict_resolution=conflict_resolution, - force=force, - ) - - # Output results - if json_output: - import json - - output = { - "success": result.success, - "status": result.status.value, - "package_name": result.package_name, - "version": result.version, - "installed_count": result.installed_count, - "skipped_count": result.skipped_count, - "failed_count": result.failed_count, - "components_installed": {k.value: v for k, v in result.components_installed.items()}, - "is_reinstall": result.is_reinstall, - "error_message": result.error_message, - } - console.print(json.dumps(output, indent=2)) - else: - _display_installation_summary(result, quiet) - - # Exit with appropriate code - if not result.success: - raise typer.Exit(1) - - except Exception as e: - console.print(f"[red]Installation failed: {e}[/red]") - raise typer.Exit(1) - - -def _display_installation_summary(result: InstallationResult, quiet: bool) -> None: - """Display installation summary to user.""" - if result.success: - # Success message - if result.status == InstallationStatus.COMPLETE: - console.print(f"\n[green]✓ Successfully installed {result.package_name} v{result.version}[/green]") - elif result.status == InstallationStatus.PARTIAL: - console.print(f"\n[yellow]⚠ Partially installed {result.package_name} v{result.version}[/yellow]") - else: - console.print(f"\n[red]✗ Installation failed for {result.package_name} v{result.version}[/red]") - - if result.is_reinstall: - console.print("[cyan] (Reinstalled existing package)[/cyan]") - - # Component summary table - if not quiet: - table = Table(title="Installation Summary", show_header=True) - table.add_column("Component Type", style="cyan") - table.add_column("Count", justify="right", style="green") - - for component_type, count in result.components_installed.items(): - table.add_row(component_type.value, str(count)) - - console.print() - console.print(table) - - # Statistics - console.print(f"\n Installed: {result.installed_count}") - if result.skipped_count > 0: - console.print(f" Skipped: {result.skipped_count}") - if result.failed_count > 0: - console.print(f" Failed: {result.failed_count}") - - else: - # Failure message - console.print(f"\n[red]✗ Installation failed for {result.package_name}[/red]") - if result.error_message: - console.print(f"[red] Error: {result.error_message}[/red]") - - -@package_app.command(name="list") -def list_packages_command( - project: Optional[str] = typer.Option( - None, - "--project", - "-p", - help="Project root directory (defaults to current directory)", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Output results as JSON", - ), -) -> None: - """ - List installed packages in a project. - - Example: - devsync package list - devsync package list --project ~/my-project - """ - try: - # Determine project root - if project: - project_root = Path(project).resolve() - if not project_root.exists(): - console.print(f"[red]Error: Project directory not found: {project}[/red]") - raise typer.Exit(1) - else: - project_root_maybe = find_project_root() - if not project_root_maybe: - console.print("[red]Error: Could not find project root. " "Use --project to specify explicitly.[/red]") - raise typer.Exit(1) - project_root = project_root_maybe - - # Get tracker - data_dir = _resolve_data_dir(project_root, ".devsync", [".ai-config-kit", ".instructionkit"]) - tracker_file = data_dir / "packages.json" - tracker = PackageTracker(tracker_file) - - # Get installed packages - packages = tracker.get_installed_packages() - - if json_output: - import json - - output = [] - for pkg in packages: - output.append( - { - "name": pkg.package_name, - "namespace": pkg.namespace, - "version": pkg.version, - "status": pkg.status.value, - "scope": pkg.scope.value, - "installed_at": pkg.installed_at.isoformat(), - "updated_at": pkg.updated_at.isoformat(), - "component_count": len(pkg.components), - } - ) - console.print(json.dumps(output, indent=2)) - else: - if not packages: - console.print("[yellow]No packages installed in this project.[/yellow]") - return - - console.print(f"\n[cyan]Installed packages in {project_root}:[/cyan]\n") - - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Package", style="green") - table.add_column("Version", style="blue") - table.add_column("Status", style="yellow") - table.add_column("Components", justify="right", style="magenta") - table.add_column("Installed", style="dim") - - for pkg in packages: - status_icon = "✓" if pkg.status == InstallationStatus.COMPLETE else "⚠" - table.add_row( - f"{pkg.namespace}/{pkg.package_name}", - pkg.version, - f"{status_icon} {pkg.status.value}", - str(len(pkg.components)), - pkg.installed_at.strftime("%Y-%m-%d %H:%M"), - ) - - console.print(table) - console.print(f"\n[dim]Total: {len(packages)} package(s)[/dim]\n") - - except Exception as e: - console.print(f"[red]Failed to list packages: {e}[/red]") - raise typer.Exit(1) - - -@package_app.command(name="uninstall") -def uninstall_package_command( - package_name: str = typer.Argument( - ..., - help="Package name to uninstall", - ), - project: Optional[str] = typer.Option( - None, - "--project", - "-p", - help="Project root directory (defaults to current directory)", - ), - yes: bool = typer.Option( - False, - "--yes", - "-y", - help="Skip confirmation prompt", - ), -) -> None: - """ - Uninstall a package from a project. - - This removes the package's files and tracking record. - - Example: - devsync package uninstall test-package - devsync package uninstall my-org/my-package --yes - """ - try: - # Determine project root - if project: - project_root = Path(project).resolve() - if not project_root.exists(): - console.print(f"[red]Error: Project directory not found: {project}[/red]") - raise typer.Exit(1) - else: - project_root_maybe = find_project_root() - if not project_root_maybe: - console.print("[red]Error: Could not find project root. " "Use --project to specify explicitly.[/red]") - raise typer.Exit(1) - project_root = project_root_maybe - - # Get tracker - data_dir = _resolve_data_dir(project_root, ".devsync", [".ai-config-kit", ".instructionkit"]) - tracker_file = data_dir / "packages.json" - tracker = PackageTracker(tracker_file) - - # Get package - package = tracker.get_package(package_name, InstallationScope.PROJECT) - if not package: - console.print(f"[red]Error: Package '{package_name}' is not installed in this project.[/red]") - raise typer.Exit(1) - - # Confirm uninstall - if not yes: - console.print("\n[yellow]Package to uninstall:[/yellow]") - console.print(f" Name: {package.package_name}") - console.print(f" Version: {package.version}") - console.print(f" Components: {len(package.components)}") - - confirm = typer.confirm("\nAre you sure you want to uninstall this package?") - if not confirm: - console.print("[yellow]Uninstall cancelled.[/yellow]") - raise typer.Exit(0) - - # Remove component files - removed_count = 0 - failed_count = 0 - for component in package.components: - try: - file_path = project_root / component.installed_path - if file_path.exists(): - file_path.unlink() - removed_count += 1 - console.print(f"[dim] Removed: {component.installed_path}[/dim]") - except Exception as e: - console.print(f"[yellow] Warning: Failed to remove {component.installed_path}: {e}[/yellow]") - failed_count += 1 - - # Remove from tracker - tracker.remove_package(package_name, InstallationScope.PROJECT) - - # Summary - console.print(f"\n[green]✓ Uninstalled {package.package_name} v{package.version}[/green]") - console.print(f" Removed {removed_count} file(s)") - if failed_count > 0: - console.print(f"[yellow] Failed to remove {failed_count} file(s)[/yellow]") - - except Exception as e: - console.print(f"[red]Failed to uninstall package: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/package_create.py b/devsync/cli/package_create.py deleted file mode 100644 index 6e8478c..0000000 --- a/devsync/cli/package_create.py +++ /dev/null @@ -1,323 +0,0 @@ -"""CLI command for creating configuration packages.""" - -import json -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.core.component_detector import ComponentDetector, DetectionResult -from devsync.core.package_creator import PackageCreationResult, PackageCreator, PackageMetadata, get_git_author -from devsync.utils.project import find_project_root - -console = Console() - - -def create_package_command( - name: Optional[str] = typer.Option( - None, - "--name", - "-n", - help="Package name (lowercase, hyphens allowed)", - ), - version: str = typer.Option( - "1.0.0", - "--version", - "-v", - help="Package version (semantic versioning)", - ), - description: Optional[str] = typer.Option( - None, - "--description", - "-d", - help="Package description", - ), - author: Optional[str] = typer.Option( - None, - "--author", - "-a", - help="Package author (defaults to git user.name)", - ), - license_: str = typer.Option( - "MIT", - "--license", - "-l", - help="Package license", - ), - output: str = typer.Option( - ".", - "--output", - "-o", - help="Output directory for package", - ), - project: Optional[str] = typer.Option( - None, - "--project", - "-p", - help="Project root directory (defaults to current directory)", - ), - interactive: bool = typer.Option( - True, - "--interactive/--no-interactive", - help="Enable interactive mode for component selection", - ), - scrub_secrets: bool = typer.Option( - True, - "--scrub-secrets/--keep-secrets", - help="Template secrets in MCP configs", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Overwrite existing package directory", - ), - quiet: bool = typer.Option( - False, - "--quiet", - "-q", - help="Minimal output", - ), - json_output: bool = typer.Option( - False, - "--json", - help="Output results as JSON", - ), -) -> None: - """ - Create a shareable configuration package from project components. - - Scans the current project for AI coding assistant configurations - (instructions, MCP servers, hooks, commands, resources) and creates - a portable package that can be installed in other projects. - - Example: - devsync package create --name my-package - devsync package create --name dev-setup --no-interactive - devsync package create --name my-pkg --output ~/packages - """ - try: - if project: - project_root = Path(project).resolve() - if not project_root.exists(): - console.print(f"[red]Error: Project directory not found: {project}[/red]") - raise typer.Exit(1) - else: - project_root_maybe = find_project_root() - if not project_root_maybe: - console.print("[red]Error: Could not find project root. " "Use --project to specify explicitly.[/red]") - raise typer.Exit(1) - project_root = project_root_maybe - - output_dir = Path(output).resolve() - if not output_dir.exists(): - console.print(f"[red]Error: Output directory not found: {output}[/red]") - raise typer.Exit(1) - - if not quiet: - console.print(f"[cyan]Scanning project: {project_root}[/cyan]") - - detector = ComponentDetector(project_root) - detection_result = detector.detect_all() - - if detection_result.total_count == 0: - console.print("[yellow]No packageable components found in this project.[/yellow]") - console.print("\n[dim]Components are detected from:[/dim]") - console.print("[dim] - Instructions: .claude/rules/, .cursor/rules/, etc.[/dim]") - console.print("[dim] - MCP servers: .claude/settings.local.json[/dim]") - console.print("[dim] - Hooks: .claude/hooks/[/dim]") - console.print("[dim] - Commands: .claude/commands/[/dim]") - console.print("[dim] - Resources: .devsync/resources/[/dim]") - raise typer.Exit(1) - - if not quiet: - _display_detected_components(detection_result) - - if not name: - if interactive: - name = typer.prompt("Package name", default=project_root.name.lower().replace(" ", "-")) - else: - console.print("[red]Error: --name is required in non-interactive mode[/red]") - raise typer.Exit(1) - - # At this point name is guaranteed to be a string (either from option or prompt) - assert name is not None - package_name: str = name.lower().replace(" ", "-") - if not package_name.replace("-", "").replace("_", "").isalnum(): - console.print( - f"[red]Error: Invalid package name '{package_name}'. " - f"Use only lowercase letters, numbers, hyphens.[/red]" - ) - raise typer.Exit(1) - - package_description: str - if not description: - if interactive: - package_description = typer.prompt( - "Package description", - default=f"Configuration package for {project_root.name}", - ) - else: - package_description = f"Configuration package for {project_root.name}" - else: - package_description = description - - package_author: str - if not author: - git_author = get_git_author() - if not git_author and interactive: - package_author = typer.prompt("Author", default="Unknown") - elif not git_author: - package_author = "Unknown" - else: - package_author = git_author - else: - package_author = author - - package_dir = output_dir / f"package-{package_name}" - if package_dir.exists(): - if force: - if not quiet: - console.print(f"[yellow]Removing existing package directory: {package_dir}[/yellow]") - import shutil - - shutil.rmtree(package_dir) - else: - console.print(f"[red]Error: Package directory already exists: {package_dir}[/red]") - console.print("[dim]Use --force to overwrite[/dim]") - raise typer.Exit(1) - - if interactive: - console.print(f"\n[cyan]Creating package with {detection_result.total_count} components[/cyan]") - if not typer.confirm("Proceed?", default=True): - console.print("[yellow]Package creation cancelled.[/yellow]") - raise typer.Exit(0) - - metadata = PackageMetadata( - name=package_name, - version=version, - description=package_description, - author=package_author, - license=license_, - namespace="local/local", - ) - - creator = PackageCreator( - project_root=project_root, - output_dir=output_dir, - metadata=metadata, - scrub_secrets=scrub_secrets, - ) - - result = creator.create(detection_result=detection_result) - - if json_output: - output_data = { - "success": result.success, - "package_path": str(result.package_path) if result.package_path else None, - "manifest_path": str(result.manifest_path) if result.manifest_path else None, - "components_included": result.components_included, - "secrets_templated": result.secrets_templated, - "warnings": result.warnings, - "error_message": result.error_message, - } - console.print(json.dumps(output_data, indent=2)) - else: - _display_creation_result(result, quiet) - - if not result.success: - raise typer.Exit(1) - - except typer.Exit: - raise - except Exception as e: - console.print(f"[red]Package creation failed: {e}[/red]") - raise typer.Exit(1) - - -def _display_detected_components(detection_result: "DetectionResult") -> None: - """Display summary of detected components.""" - console.print("\n[cyan]Detected components:[/cyan]") - - table = Table(show_header=True, header_style="bold cyan") - table.add_column("Type", style="green") - table.add_column("Count", justify="right", style="blue") - table.add_column("Details", style="dim") - - if detection_result.instructions: - names = ", ".join(i.name for i in detection_result.instructions[:3]) - if len(detection_result.instructions) > 3: - names += f", +{len(detection_result.instructions) - 3} more" - table.add_row("Instructions", str(len(detection_result.instructions)), names) - - if detection_result.mcp_servers: - names = ", ".join(m.name for m in detection_result.mcp_servers[:3]) - if len(detection_result.mcp_servers) > 3: - names += f", +{len(detection_result.mcp_servers) - 3} more" - table.add_row("MCP Servers", str(len(detection_result.mcp_servers)), names) - - if detection_result.hooks: - names = ", ".join(h.name for h in detection_result.hooks[:3]) - if len(detection_result.hooks) > 3: - names += f", +{len(detection_result.hooks) - 3} more" - table.add_row("Hooks", str(len(detection_result.hooks)), names) - - if detection_result.commands: - names = ", ".join(c.name for c in detection_result.commands[:3]) - if len(detection_result.commands) > 3: - names += f", +{len(detection_result.commands) - 3} more" - table.add_row("Commands", str(len(detection_result.commands)), names) - - if detection_result.resources: - names = ", ".join(r.name for r in detection_result.resources[:3]) - if len(detection_result.resources) > 3: - names += f", +{len(detection_result.resources) - 3} more" - table.add_row("Resources", str(len(detection_result.resources)), names) - - if detection_result.skills: - names = ", ".join(s.name for s in detection_result.skills[:3]) - if len(detection_result.skills) > 3: - names += f", +{len(detection_result.skills) - 3} more" - table.add_row("Skills", str(len(detection_result.skills)), names) - - if detection_result.workflows: - names = ", ".join(w.name for w in detection_result.workflows[:3]) - if len(detection_result.workflows) > 3: - names += f", +{len(detection_result.workflows) - 3} more" - table.add_row("Workflows", str(len(detection_result.workflows)), names) - - if detection_result.memory_files: - names = ", ".join(m.name for m in detection_result.memory_files[:3]) - if len(detection_result.memory_files) > 3: - names += f", +{len(detection_result.memory_files) - 3} more" - table.add_row("Memory Files", str(len(detection_result.memory_files)), names) - - console.print(table) - console.print(f"\n[dim]Total: {detection_result.total_count} component(s)[/dim]") - - -def _display_creation_result(result: PackageCreationResult, quiet: bool) -> None: - """Display package creation result.""" - if result.success: - console.print("\n[green]✓ Package created successfully![/green]") - console.print(f" Location: {result.package_path}") - - if not quiet: - console.print(f" Components: {result.components_included}") - if result.secrets_templated > 0: - console.print(f" Secrets templated: {result.secrets_templated}") - - if result.warnings: - console.print(f"\n[yellow]Warnings ({len(result.warnings)}):[/yellow]") - for warning in result.warnings: - console.print(f" - {warning}") - - console.print("\n[cyan]To install this package:[/cyan]") - console.print(f" devsync package install {result.package_path} --ide claude") - - else: - console.print("\n[red]✗ Package creation failed[/red]") - if result.error_message: - console.print(f" Error: {result.error_message}") diff --git a/devsync/cli/package_install.py b/devsync/cli/package_install.py deleted file mode 100644 index 96a6a4e..0000000 --- a/devsync/cli/package_install.py +++ /dev/null @@ -1,474 +0,0 @@ -"""Package installation command and logic.""" - -import logging -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any, Optional - -from devsync.ai_tools.capability_registry import get_capability -from devsync.ai_tools.translator import get_translator -from devsync.core.models import ( - AIToolType, - ComponentStatus, - ComponentType, - ConflictResolution, - InstallationScope, - InstallationStatus, - InstalledComponent, - Package, - PackageInstallationRecord, -) -from devsync.core.package_manifest import PackageManifestParser -from devsync.storage.package_tracker import PackageTracker -from devsync.utils.paths import _resolve_data_dir - -logger = logging.getLogger(__name__) - - -@dataclass -class InstallationResult: - """Result of package installation operation.""" - - success: bool - status: InstallationStatus - package_name: str - version: str - installed_count: int = 0 - skipped_count: int = 0 - failed_count: int = 0 - components_installed: dict[ComponentType, int] = field(default_factory=dict) - error_message: Optional[str] = None - is_reinstall: bool = False - - @property - def total_components(self) -> int: - """Total number of components processed.""" - return self.installed_count + self.skipped_count + self.failed_count - - -def install_package( - package_path: Path, - project_root: Path, - target_ide: AIToolType, - scope: InstallationScope = InstallationScope.PROJECT, - conflict_resolution: ConflictResolution = ConflictResolution.SKIP, - force: bool = False, -) -> InstallationResult: - """ - Install a package to a project for a specific IDE. - - Args: - package_path: Path to package directory containing manifest - project_root: Root directory of target project - target_ide: Target IDE for installation - scope: Installation scope (project or global) - conflict_resolution: How to handle file conflicts - force: Force reinstallation even if already installed - - Returns: - InstallationResult with details of installation - - Raises: - FileNotFoundError: If manifest not found - ValidationError: If manifest is invalid - """ - logger.info(f"Installing package from {package_path} to {project_root} for {target_ide.value}") - - try: - # Step 1: Parse and validate manifest - parser = PackageManifestParser(package_path) - package = parser.parse() - - # Validate manifest - validation_errors = parser.validate(package) - if validation_errors: - error_msg = "; ".join(validation_errors) - return InstallationResult( - success=False, - status=InstallationStatus.FAILED, - package_name=package.name, - version=package.version, - error_message=f"Manifest validation failed: {error_msg}", - ) - - logger.info(f"Parsed package: {package.name} v{package.version}") - - # Step 2: Check if already installed - data_dir = _resolve_data_dir(project_root, ".devsync", [".ai-config-kit", ".instructionkit"]) - tracker_file = data_dir / "packages.json" - tracker = PackageTracker(tracker_file) - is_reinstall = tracker.is_package_installed(package.name, scope) - - if is_reinstall and not force: - logger.info(f"Package {package.name} already installed") - - # Step 3: Get IDE capabilities - capability = get_capability(target_ide) - logger.debug(f"Target IDE capabilities: {capability.supported_components}") - - # Step 4: Get component translator - translator = get_translator(target_ide) - - # Step 5: Filter components by IDE capabilities and track skipped - installable_components = _filter_components_by_capability(package, capability) - - # Calculate skipped components (filtered out by IDE capabilities) - total_in_package = package.components.total_count - installable_count = sum(len(comps) for comps in installable_components.values()) - capability_skipped = total_in_package - installable_count - - logger.info( - f"Found {installable_count} installable components, {capability_skipped} filtered by IDE capabilities" - ) - - # Step 6: Install each component type - installed_components: list[InstalledComponent] = [] - installed_count = 0 - skipped_count = capability_skipped # Start with capability-filtered components - failed_count = 0 - components_by_type: dict[ComponentType, int] = {} - - # Install instructions - for instruction in installable_components.get("instructions", []): - result = _install_instruction_component( - instruction, - package_path, - project_root, - translator, - conflict_resolution, - ) - if result: - installed_components.append(result) - installed_count += 1 - components_by_type[ComponentType.INSTRUCTION] = components_by_type.get(ComponentType.INSTRUCTION, 0) + 1 - else: - skipped_count += 1 - - # Install MCP servers - for mcp in installable_components.get("mcp_servers", []): - result = _install_mcp_component(mcp, package_path, project_root, translator, conflict_resolution) - if result: - installed_components.append(result) - installed_count += 1 - components_by_type[ComponentType.MCP_SERVER] = components_by_type.get(ComponentType.MCP_SERVER, 0) + 1 - else: - skipped_count += 1 - - # Install hooks - for hook in installable_components.get("hooks", []): - result = _install_hook_component(hook, package_path, project_root, translator, conflict_resolution) - if result: - installed_components.append(result) - installed_count += 1 - components_by_type[ComponentType.HOOK] = components_by_type.get(ComponentType.HOOK, 0) + 1 - else: - skipped_count += 1 - - # Install commands - for command in installable_components.get("commands", []): - result = _install_command_component(command, package_path, project_root, translator, conflict_resolution) - if result: - installed_components.append(result) - installed_count += 1 - components_by_type[ComponentType.COMMAND] = components_by_type.get(ComponentType.COMMAND, 0) + 1 - else: - skipped_count += 1 - - # Install resources - for resource in installable_components.get("resources", []): - result = _install_resource_component(resource, package_path, project_root, translator, conflict_resolution) - if result: - installed_components.append(result) - installed_count += 1 - components_by_type[ComponentType.RESOURCE] = components_by_type.get(ComponentType.RESOURCE, 0) + 1 - else: - skipped_count += 1 - - # Step 7: Determine installation status - total_in_package = package.components.total_count - if installed_count == 0: - status = InstallationStatus.FAILED - elif installed_count == total_in_package: - status = InstallationStatus.COMPLETE - else: - status = InstallationStatus.PARTIAL - - # Step 8: Record installation - now = datetime.now() - # Get original install time for reinstalls - existing_record = tracker.get_package(package.name, scope) if is_reinstall else None - original_install_time = existing_record.installed_at if existing_record else now - - installation_record = PackageInstallationRecord( - package_name=package.name, - namespace=package.namespace, - version=package.version, - installed_at=original_install_time, - updated_at=now, - scope=scope, - components=installed_components, - status=status, - ) - tracker.record_installation(installation_record) - - logger.info( - f"Installation complete: {installed_count} installed, {skipped_count} skipped, {failed_count} failed" - ) - - return InstallationResult( - success=True, - status=status, - package_name=package.name, - version=package.version, - installed_count=installed_count, - skipped_count=skipped_count, - failed_count=failed_count, - components_installed=components_by_type, - is_reinstall=is_reinstall, - ) - - except Exception as e: - logger.error(f"Installation failed: {e}", exc_info=True) - return InstallationResult( - success=False, - status=InstallationStatus.FAILED, - package_name=package_path.name if package_path else "unknown", - version="unknown", - error_message=str(e), - ) - - -def _filter_components_by_capability(package: Package, capability: Any) -> dict[str, list[Any]]: - """Filter package components by IDE capability.""" - filtered: dict[str, list[Any]] = {} - - if capability.supports_component(ComponentType.INSTRUCTION): - filtered["instructions"] = package.components.instructions - - if capability.supports_component(ComponentType.MCP_SERVER): - filtered["mcp_servers"] = package.components.mcp_servers - - if capability.supports_component(ComponentType.HOOK): - filtered["hooks"] = package.components.hooks - - if capability.supports_component(ComponentType.COMMAND): - filtered["commands"] = package.components.commands - - if capability.supports_component(ComponentType.RESOURCE): - filtered["resources"] = package.components.resources - - return filtered - - -def _install_instruction_component( - component: Any, package_path: Path, project_root: Path, translator: Any, conflict_resolution: ConflictResolution -) -> Optional[InstalledComponent]: - """Install instruction component.""" - try: - # Translate component - translated = translator.translate_instruction(component, package_path) - - # Determine target path - target_file = project_root / translated.target_path - - # Check for conflicts - if target_file.exists(): - if conflict_resolution == ConflictResolution.SKIP: - logger.info(f"Skipping existing file: {target_file}") - return None - elif conflict_resolution == ConflictResolution.RENAME: - # Find available numbered suffix - counter = 1 - stem = target_file.stem - suffix = target_file.suffix - while target_file.exists(): - target_file = target_file.parent / f"{stem}-{counter}{suffix}" - counter += 1 - logger.info(f"Renaming to avoid conflict: {target_file}") - - # Install file - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text(translated.content) - - # Calculate checksum - from devsync.core.checksum import calculate_file_checksum - - checksum = calculate_file_checksum(str(target_file), "sha256") - - return InstalledComponent( - type=ComponentType.INSTRUCTION, - name=component.name, - installed_path=str(target_file.relative_to(project_root)), - checksum=checksum, - status=ComponentStatus.INSTALLED, - ) - - except Exception as e: - logger.error(f"Failed to install instruction {component.name}: {e}") - return None - - -def _install_mcp_component( - component: Any, package_path: Path, project_root: Path, translator: Any, conflict_resolution: ConflictResolution -) -> Optional[InstalledComponent]: - """Install MCP server component.""" - try: - # Translate component - translated = translator.translate_mcp_server(component, package_path) - target_file = project_root / translated.target_path - - # Check for conflicts - if target_file.exists() and conflict_resolution == ConflictResolution.SKIP: - return None - - # Create target directory - target_file.parent.mkdir(parents=True, exist_ok=True) - - # Write MCP config file - target_file.write_text(translated.content) - - # Calculate checksum - from devsync.core.checksum import calculate_file_checksum - - checksum = calculate_file_checksum(str(target_file), "sha256") - - return InstalledComponent( - type=ComponentType.MCP_SERVER, - name=component.name, - installed_path=str(target_file.relative_to(project_root)), - checksum=checksum, - status=ComponentStatus.INSTALLED, - ) - - except Exception as e: - logger.error(f"Failed to install MCP server {component.name}: {e}") - return None - - -def _install_hook_component( - component: Any, package_path: Path, project_root: Path, translator: Any, conflict_resolution: ConflictResolution -) -> Optional[InstalledComponent]: - """Install hook component.""" - try: - translated = translator.translate_hook(component, package_path) - target_file = project_root / translated.target_path - - # Check for conflicts - if target_file.exists(): - if conflict_resolution == ConflictResolution.SKIP: - logger.info(f"Skipping existing file: {target_file}") - return None - elif conflict_resolution == ConflictResolution.RENAME: - # Find available numbered suffix - counter = 1 - stem = target_file.stem - suffix = target_file.suffix - while target_file.exists(): - target_file = target_file.parent / f"{stem}-{counter}{suffix}" - counter += 1 - logger.info(f"Renaming to avoid conflict: {target_file}") - - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text(translated.content) - target_file.chmod(0o755) # Make executable - - from devsync.core.checksum import calculate_file_checksum - - checksum = calculate_file_checksum(str(target_file), "sha256") - - return InstalledComponent( - type=ComponentType.HOOK, - name=component.name, - installed_path=str(target_file.relative_to(project_root)), - checksum=checksum, - status=ComponentStatus.INSTALLED, - ) - - except Exception as e: - logger.error(f"Failed to install hook {component.name}: {e}") - return None - - -def _install_command_component( - component: Any, package_path: Path, project_root: Path, translator: Any, conflict_resolution: ConflictResolution -) -> Optional[InstalledComponent]: - """Install command component.""" - try: - translated = translator.translate_command(component, package_path) - target_file = project_root / translated.target_path - - # Check for conflicts - if target_file.exists(): - if conflict_resolution == ConflictResolution.SKIP: - logger.info(f"Skipping existing file: {target_file}") - return None - elif conflict_resolution == ConflictResolution.RENAME: - # Find available numbered suffix - counter = 1 - stem = target_file.stem - suffix = target_file.suffix - while target_file.exists(): - target_file = target_file.parent / f"{stem}-{counter}{suffix}" - counter += 1 - logger.info(f"Renaming to avoid conflict: {target_file}") - - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text(translated.content) - target_file.chmod(0o755) # Make executable - - from devsync.core.checksum import calculate_file_checksum - - checksum = calculate_file_checksum(str(target_file), "sha256") - - return InstalledComponent( - type=ComponentType.COMMAND, - name=component.name, - installed_path=str(target_file.relative_to(project_root)), - checksum=checksum, - status=ComponentStatus.INSTALLED, - ) - - except Exception as e: - logger.error(f"Failed to install command {component.name}: {e}") - return None - - -def _install_resource_component( - component: Any, package_path: Path, project_root: Path, translator: Any, conflict_resolution: ConflictResolution -) -> Optional[InstalledComponent]: - """Install resource component.""" - try: - import shutil - - translated = translator.translate_resource(component, package_path) - target_file = project_root / translated.target_path - - if target_file.exists() and conflict_resolution == ConflictResolution.SKIP: - return None - - # Copy file directly (handles both text and binary) - source_path = translated.metadata.get("source_path") - if source_path: - target_file.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source_path, target_file) - else: - # Fallback to writing content (for compatibility) - target_file.parent.mkdir(parents=True, exist_ok=True) - target_file.write_text(translated.content) - - from devsync.core.checksum import calculate_file_checksum - - checksum = calculate_file_checksum(str(target_file), "sha256") - - return InstalledComponent( - type=ComponentType.RESOURCE, - name=component.name, - installed_path=str(target_file.relative_to(project_root)), - checksum=checksum, - status=ComponentStatus.INSTALLED, - ) - - except Exception as e: - logger.error(f"Failed to install resource {component.name}: {e}") - return None diff --git a/devsync/cli/template.py b/devsync/cli/template.py deleted file mode 100644 index 1135dc1..0000000 --- a/devsync/cli/template.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Template management commands.""" - -import typer - -# Create template subcommand group -template_app = typer.Typer( - name="template", - help="Manage template repositories for consistent project standards", - add_completion=False, -) - - -@template_app.callback(invoke_without_command=True) -def template_callback(ctx: typer.Context) -> None: - """Manage template repositories.""" - # If no subcommand was provided, show help - if ctx.invoked_subcommand is None: - typer.echo(ctx.get_help()) - raise typer.Exit(0) diff --git a/devsync/cli/template_backup.py b/devsync/cli/template_backup.py deleted file mode 100644 index 5ed5a94..0000000 --- a/devsync/cli/template_backup.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Template backup management commands.""" - -from pathlib import Path - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.utils.backup import cleanup_old_backups, list_backups, restore_backup -from devsync.utils.paths import _resolve_data_dir -from devsync.utils.project import find_project_root - -console = Console() - - -def backup_list_command( - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Which backups to list (project, global)", - ), - limit: int = typer.Option( - 10, - "--limit", - "-n", - help="Maximum number of backups to show", - ), -) -> None: - """ - List available template backups. - - Backups are created automatically before overwriting templates - during update operations. - - Example: - devsync template backup list - devsync template backup list --scope global - devsync template backup list --limit 20 - """ - try: - # Determine backup directory - if scope == "project": - project_root = find_project_root() - if not project_root: - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - backup_dir = _resolve_data_dir(project_root, ".devsync", [".instructionkit", ".ai-config-kit"]) / "backups" - elif scope == "global": - backup_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "backups" - else: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project' or 'global'[/red]") - raise typer.Exit(1) - - # Get backups - backups = list_backups(backup_dir) - - if not backups: - console.print(f"[yellow]No backups found in {scope} scope[/yellow]") - console.print(f"[dim]Backup directory: {backup_dir}[/dim]") - return - - # Limit results - backups = backups[:limit] - - # Display table - table = Table(title=f"\n{scope.capitalize()} Template Backups") - table.add_column("Timestamp", style="cyan") - table.add_column("Backup Directory", style="green") - table.add_column("Files", justify="right") - - for timestamp, backup_path in backups: - file_count = len(list(backup_path.iterdir())) - timestamp_str = timestamp.strftime("%Y-%m-%d %H:%M:%S") - table.add_row(timestamp_str, str(backup_path.relative_to(backup_dir.parent)), str(file_count)) - - console.print(table) - console.print(f"\n[dim]Showing {len(backups)} most recent backup(s)[/dim]") - - if len(backups) >= limit: - console.print("[dim]Use --limit to see more backups[/dim]") - - except typer.Exit: - raise - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) - - -def backup_cleanup_command( - days: int = typer.Option( - 30, - "--days", - "-d", - help="Remove backups older than this many days", - ), - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Which backups to clean up (project, global)", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Skip confirmation prompt", - ), -) -> None: - """ - Remove old template backups to free up space. - - Example: - devsync template backup cleanup --days 30 - devsync template backup cleanup --days 7 --force - devsync template backup cleanup --scope global --days 90 - """ - try: - # Determine backup directory - if scope == "project": - project_root = find_project_root() - if not project_root: - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - backup_dir = _resolve_data_dir(project_root, ".devsync", [".instructionkit", ".ai-config-kit"]) / "backups" - elif scope == "global": - backup_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "backups" - else: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project' or 'global'[/red]") - raise typer.Exit(1) - - # Get backups that will be removed - backups = list_backups(backup_dir) - from datetime import datetime - - cutoff_date = datetime.now().timestamp() - (days * 24 * 60 * 60) - old_backups = [b for timestamp, b in backups if timestamp.timestamp() < cutoff_date] - - if not old_backups: - console.print(f"[green]No backups older than {days} days found[/green]") - return - - # Confirm deletion - if not force: - console.print(f"[yellow]Found {len(old_backups)} backup(s) older than {days} days:[/yellow]") - for backup_path in old_backups[:5]: # Show first 5 - console.print(f" - {backup_path.name}") - if len(old_backups) > 5: - console.print(f" ... and {len(old_backups) - 5} more") - - confirm = typer.confirm(f"\nRemove {len(old_backups)} old backup(s)?") - if not confirm: - console.print("[yellow]Cancelled[/yellow]") - raise typer.Exit(0) - - # Clean up - removed = cleanup_old_backups(days, backup_dir) - console.print(f"[green]✓ Removed {removed} old backup(s)[/green]") - - except typer.Exit: - raise - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) - - -def backup_restore_command( - backup_timestamp: str = typer.Argument(..., help="Timestamp of backup to restore (YYYYMMDD_HHMMSS)"), - file_name: str = typer.Argument(..., help="Name of file to restore"), - target: str = typer.Option( - None, - "--target", - "-t", - help="Target path (default: original location)", - ), - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Where backup is located (project, global)", - ), -) -> None: - """ - Restore a file from a backup. - - Example: - # List backups first - devsync template backup list - - # Restore specific file - devsync template backup restore 20251109_143052 company.test.md - - # Restore to different location - devsync template backup restore 20251109_143052 company.test.md --target .claude/rules/company.test-restored.md - """ - try: - # Determine backup directory - if scope == "project": - project_root = find_project_root() - if not project_root: - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - backup_dir = _resolve_data_dir(project_root, ".devsync", [".instructionkit", ".ai-config-kit"]) / "backups" - elif scope == "global": - backup_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) / "backups" - else: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project' or 'global'[/red]") - raise typer.Exit(1) - - # Find backup - backup_path = backup_dir / backup_timestamp / file_name - if not backup_path.exists(): - console.print(f"[red]Error: Backup not found: {backup_path}[/red]") - console.print("\n[yellow]Available backups:[/yellow]") - # Show available backups for this timestamp - timestamp_dir = backup_dir / backup_timestamp - if timestamp_dir.exists(): - for f in timestamp_dir.iterdir(): - console.print(f" - {f.name}") - else: - console.print(f"[red]Backup directory not found: {timestamp_dir}[/red]") - console.print("\nUse 'devsync template backup list' to see available backups") - raise typer.Exit(1) - - # Determine target path - if target: - target_path = Path(target) - else: - # Try to restore to original location based on file name - # Assume structure like .claude/rules/company.test.md - parts = file_name.split(".") - if len(parts) >= 3: - # namespace.template-name.ext format - # Default to .claude/rules as restore location - target_path = Path(f".claude/rules/{file_name}") - else: - console.print("[yellow]Could not determine original location[/yellow]") - console.print("Please specify --target path") - raise typer.Exit(1) - - # Confirm restore - console.print("[yellow]Restore file:[/yellow]") - console.print(f" From: {backup_path}") - console.print(f" To: {target_path}") - - if target_path.exists(): - console.print("\n[red]⚠️ Target file exists and will be overwritten[/red]") - - confirm = typer.confirm("\nProceed with restore?") - if not confirm: - console.print("[yellow]Cancelled[/yellow]") - raise typer.Exit(0) - - # Restore - restore_backup(backup_path, target_path) - console.print(f"[green]✓ File restored to {target_path}[/green]") - - except typer.Exit: - raise - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_init.py b/devsync/cli/template_init.py deleted file mode 100644 index a4b1de2..0000000 --- a/devsync/cli/template_init.py +++ /dev/null @@ -1,499 +0,0 @@ -"""Template repository scaffolding command.""" - -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console - -console = Console() - - -def init_command( - directory: str = typer.Argument(..., help="Directory name for new template repository"), - namespace: Optional[str] = typer.Option( - None, - "--namespace", - "-n", - help="Default namespace for templates (default: directory name)", - ), - description: Optional[str] = typer.Option( - None, - "--description", - "-d", - help="Repository description", - ), - author: Optional[str] = typer.Option( - None, - "--author", - "-a", - help="Author name", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Overwrite existing directory", - ), -) -> None: - """ - Create a new template repository with scaffolded structure. - - This command creates a ready-to-use template repository with: - - Properly formatted templatekit.yaml - - Example templates with documentation - - Directory structure for instructions, commands, and hooks - - README with usage instructions - - .gitignore for Python projects - - Example: - # Create basic template repo - devsync template init my-templates - - # Create with custom namespace and description - devsync template init company-standards \\ - --namespace acme \\ - --description "ACME Corp engineering standards" \\ - --author "ACME Engineering Team" - - # Overwrite existing directory - devsync template init my-templates --force - """ - try: - # Handle typer.Option objects when called directly (in tests) - # When called via CLI, Typer processes these; when called directly, they remain as Option objects - import typer.models - - if isinstance(namespace, typer.models.OptionInfo): - namespace = None - if isinstance(description, typer.models.OptionInfo): - description = None - if isinstance(author, typer.models.OptionInfo): - author = None - - # Convert to Path - repo_path = Path(directory).resolve() - - # Check if directory exists - if repo_path.exists() and not force: - console.print(f"[red]Error: Directory '{directory}' already exists[/red]") - console.print("Use --force to overwrite") - raise typer.Exit(1) - - # Use directory name as default namespace - if namespace is None: - namespace = directory.replace("-", "_").replace(" ", "_") - - # Set defaults - if description is None: - description = f"Template repository for {namespace}" - if author is None: - author = "Your Name" - - # Create directory structure - console.print(f"\n[cyan]Creating template repository: {directory}[/cyan]\n") - - repo_path.mkdir(parents=True, exist_ok=True) - - # Create IDE-agnostic template directories - (repo_path / "templates" / "instructions").mkdir(parents=True, exist_ok=True) - (repo_path / "templates" / "commands").mkdir(parents=True, exist_ok=True) - (repo_path / "templates" / "hooks").mkdir(parents=True, exist_ok=True) - - # Create templatekit.yaml - manifest_content = f"""# Template Repository Manifest -# See: https://github.com/troylar/devsync - -name: {description} -description: {description} -version: 1.0.0 -author: {author} - -templates: - # Example instruction/rule template - - name: example-instruction - description: Example coding standards and best practices - files: - - path: templates/instructions/example-instruction.md - ide: all # IDE-agnostic: works with Claude, Cursor, Windsurf, Copilot - tags: [example, standards] - - # Example slash command template - - name: example-command - description: Example slash command for common task - files: - - path: templates/commands/example-command.md - ide: all # IDE-agnostic: works with any supported IDE - tags: [example, productivity] - - # Example hook template - - name: example-hook - description: Example pre-prompt hook for context injection - files: - - path: templates/hooks/example-hook.md - ide: all # IDE-agnostic: works with any supported IDE - tags: [example, automation] - -# Optional: Group related templates into bundles -bundles: - - name: getting-started - description: Example bundle with all starter templates - templates: - - example-instruction - - example-command - tags: [example] -""" - (repo_path / "templatekit.yaml").write_text(manifest_content, encoding="utf-8") - console.print("✓ Created templatekit.yaml") - - # Create example instruction - instruction_content = """# Example Coding Standards - -This is an example instruction/rule template. It will appear in your IDE's rules/instructions. - -## Purpose - -Replace this content with your team's coding standards, best practices, or guidelines. - -## What to Include - -- **Coding Standards**: Formatting, naming conventions, style guides -- **Best Practices**: Design patterns, error handling, testing approaches -- **Team Conventions**: PR guidelines, commit message formats, branch naming -- **Security**: OWASP guidelines, authentication patterns, data handling - -## Example: Python Coding Standards - -### Naming Conventions -- Use `snake_case` for functions and variables -- Use `PascalCase` for classes -- Use `UPPER_CASE` for constants - -### Type Hints -Always use type hints for function signatures: - -```python -def process_data(input: str, count: int = 10) -> list[str]: - \"\"\"Process input data and return results.\"\"\" - return input.split()[:count] -``` - -### Documentation -All public functions must have docstrings following Google style. - -## Customization - -1. Replace this content with your standards -2. Add multiple instruction files for different topics -3. Update templatekit.yaml to reference new files -4. Commit to Git and share with your team! - ---- -*Generated by DevSync - https://github.com/troylar/devsync* -""" - (repo_path / "templates" / "instructions" / "example-instruction.md").write_text( - instruction_content, encoding="utf-8" - ) - console.print("✓ Created templates/instructions/example-instruction.md") - - # Create example command - command_content = """# Example Command - -This is an example slash command template. Users can invoke it with `/example-command`. - -## Purpose - -Replace this with your custom command logic. Slash commands are powerful automation tools. - -## Command Instructions - -When this command is invoked: - -1. **Analyze** the current project context -2. **Perform** the specific task (testing, refactoring, code review, etc.) -3. **Report** results back to the user - -## Example: Run Tests Command - -```markdown -# Run Tests - -I will run the project's test suite and provide a comprehensive report. - -## Steps: -1. Detect test framework (pytest, unittest, jest, etc.) -2. Run all tests with coverage -3. Parse and summarize results -4. Highlight failing tests with details -5. Show coverage metrics - -## Execution: -- Run: `pytest --cov --cov-report=term` -- Parse output -- Create summary table -``` - -## Customization Ideas - -**Common Commands:** -- `/test-api` - Run API integration tests -- `/review-pr` - Perform code review checklist -- `/generate-docs` - Auto-generate documentation -- `/refactor` - Suggest refactoring improvements -- `/security-scan` - Check for security issues - -**Best Practices:** -1. Clear purpose and expected output -2. Step-by-step execution plan -3. Error handling instructions -4. Output formatting guidelines - ---- -*Generated by DevSync - https://github.com/troylar/devsync* -""" - (repo_path / "templates" / "commands" / "example-command.md").write_text(command_content, encoding="utf-8") - console.print("✓ Created templates/commands/example-command.md") - - # Create example hook - hook_content = """# Example Pre-Prompt Hook - -This is an example hook that runs before each AI prompt. - -## Purpose - -Hooks automatically inject context or modify AI behavior without manual intervention. - -## Hook Types - -### Pre-Prompt Hook -Runs before user's prompt is sent. Use for: -- Adding project context -- Injecting recent changes -- Setting behavioral guidelines - -### Post-Response Hook -Runs after AI response. Use for: -- Logging interactions -- Validating output -- Triggering follow-up actions - -## Example: Context Injection - -```markdown -Before responding, please consider: - -## Project Context -- Framework: Django 4.2 -- Python: 3.11 -- Database: PostgreSQL -- Deployment: AWS ECS - -## Current Sprint -Focus: API performance optimization -Priority: Reduce response times by 30% - -## Recent Changes -[Automatically inject git log summary] -``` - -## Customization - -1. Replace with your project-specific context -2. Add dynamic content (git logs, recent files, etc.) -3. Set team-wide guidelines -4. Configure IDE-specific behavior - -## Best Practices - -- Keep hooks concise (< 200 words) -- Focus on actionable context -- Update regularly as project evolves -- Test hook behavior before deploying - ---- -*Generated by DevSync - https://github.com/troylar/devsync* -""" - (repo_path / "templates" / "hooks" / "example-hook.md").write_text(hook_content, encoding="utf-8") - console.print("✓ Created templates/hooks/example-hook.md") - - # Create README.md - readme_content = f"""# {description} - -Template repository for DevSync - distributes IDE-specific artifacts -(instructions, commands, hooks) to your team. - -## 📦 What's Included - -This repository contains templates for AI coding tools (Claude Code, Cursor, Windsurf, GitHub Copilot): - -- **Instructions/Rules** - Coding standards and best practices -- **Commands** - Slash commands for common workflows -- **Hooks** - Automation hooks for context injection - -## 🚀 Installation - -Team members can install these templates using DevSync: - -```bash -# Install from this repository -devsync template install --as {namespace} - -# List installed templates -devsync template list - -# Validate installation -devsync template validate -``` - -## 📝 Usage - -After installation, templates are installed to your IDE-specific directories: -- **Claude Code**: `.claude/rules/`, `.claude/commands/`, `.claude/hooks/` -- **Cursor**: `.cursor/rules/` -- **Windsurf**: `.windsurf/rules/` -- **GitHub Copilot**: `.github/copilot/instructions/` - -The template system automatically translates to the correct format for each IDE. - -## 🛠️ Customization - -### Adding New Templates - -1. Create template file in the `templates/` directory (IDE-agnostic): - - Instructions: `templates/instructions/my-template.md` - - Commands: `templates/commands/my-command.md` - - Hooks: `templates/hooks/my-hook.md` - -2. Register in `templatekit.yaml`: - -```yaml -templates: - - name: my-template - description: Description of what this template does - files: - - path: templates/instructions/my-template.md - ide: all # Works with any IDE - tags: [your, tags] -``` - -3. Commit and push: - -```bash -git add . -git commit -m "feat: add my-template" -git push -``` - -4. Team members update: - -```bash -devsync template update {namespace} -``` - -### Template Types - -- `instruction` - Coding standards, guidelines, best practices -- `command` - Slash commands for automation -- `hook` - Pre/post-prompt hooks for context - -### Bundles - -Group related templates: - -```yaml -bundles: - - name: python-stack - description: Complete Python development setup - templates: - - python-standards - - test-command - - pre-prompt-hook - tags: [python] -``` - -## 📚 Documentation - -- [DevSync Documentation](https://github.com/troylar/devsync) -- [Template System Guide](https://github.com/troylar/devsync#templates) -- [Manifest Reference](https://github.com/troylar/devsync#template-repository-structure) - -## 🤝 Contributing - -1. Create feature branch: `git checkout -b feature/new-template` -2. Add/modify templates -3. Update `templatekit.yaml` -4. Test locally: `devsync template install . --as {namespace}` -5. Commit and push -6. Create pull request - -## 📄 License - -Add your license here (MIT, Apache 2.0, etc.) - ---- -*Generated by [DevSync](https://github.com/troylar/devsync)* -""" - (repo_path / "README.md").write_text(readme_content, encoding="utf-8") - console.print("✓ Created README.md") - - # Create .gitignore - gitignore_content = """# DevSync -.devsync/ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -venv/ -env/ -ENV/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -.DS_Store - -# Distribution -dist/ -build/ -*.egg-info/ -""" - (repo_path / ".gitignore").write_text(gitignore_content, encoding="utf-8") - console.print("✓ Created .gitignore") - - # Success message - console.print("\n[green]✓ Template repository created successfully![/green]\n") - - console.print("[cyan]Next steps:[/cyan]") - console.print(f" 1. cd {directory}") - console.print(" 2. Customize templates in templates/ directory") - console.print(" 3. Update templatekit.yaml with your templates") - console.print(" 4. Initialize git: git init && git add . && git commit -m 'Initial commit'") - console.print(" 5. Push to GitHub/GitLab/Bitbucket") - console.print(f" 6. Install to any IDE: devsync template install --as {namespace} --ide ") - - console.print("\n[cyan]Repository structure (IDE-agnostic):[/cyan]") - console.print(f"{directory}/") - console.print("├── templatekit.yaml # Template manifest") - console.print("├── README.md # Usage documentation") - console.print("├── .gitignore # Git ignore rules") - console.print("└── templates/ # IDE-agnostic templates") - console.print(" ├── instructions/") - console.print(" │ └── example-instruction.md") - console.print(" ├── commands/") - console.print(" │ └── example-command.md") - console.print(" └── hooks/") - console.print(" └── example-hook.md") - - console.print("\n[dim]Test locally:[/dim]") - console.print(f" devsync template install {repo_path} --as {namespace}") - - except typer.Exit: - raise - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_install.py b/devsync/cli/template_install.py deleted file mode 100644 index 8efd698..0000000 --- a/devsync/cli/template_install.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Template installation command.""" - -import uuid -from datetime import datetime -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.table import Table - -from devsync.ai_tools.detector import get_detector -from devsync.core.checksum import sha256_string -from devsync.core.models import AIToolType, InstallationScope, TemplateInstallationRecord -from devsync.core.template_manifest import validate_dependencies, validate_manifest_size -from devsync.storage.template_library import TemplateLibraryManager -from devsync.storage.template_tracker import TemplateInstallationTracker -from devsync.utils.git_helpers import TemplateAuthError, TemplateNetworkError -from devsync.utils.namespace import derive_namespace, get_install_path -from devsync.utils.paths import _resolve_data_dir -from devsync.utils.project import find_project_root - -console = Console() - - -def install_command( - repo_url: str = typer.Argument(..., help="Git repository URL (https:// or git@)"), - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Installation scope (project or global)", - ), - namespace_override: Optional[str] = typer.Option( - None, - "--as", - help="Override namespace (default: derived from repository name)", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Overwrite existing templates without prompting", - ), -) -> None: - """ - Install templates from a repository. - - Example: - devsync template install https://github.com/acme/templates - devsync template install https://github.com/acme/templates --scope global - devsync template install https://github.com/acme/templates --as acme - """ - try: - # Validate scope - if scope not in ["project", "global"]: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project' or 'global'[/red]") - raise typer.Exit(1) - - installation_scope = InstallationScope.PROJECT if scope == "project" else InstallationScope.GLOBAL - - # Derive namespace - try: - namespace = derive_namespace(repo_url, namespace_override) - if namespace_override: - console.print(f"Using custom namespace: [cyan]{namespace}[/cyan]") - else: - console.print(f"Deriving namespace from repository: [cyan]{namespace}[/cyan]") - except ValueError as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) - - # Clone repository - library_manager = TemplateLibraryManager() - - console.print(f"\n[bold]Cloning repository from {repo_url}...[/bold]") - - with Progress( - SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console - ) as progress: - task = progress.add_task("Cloning repository...", total=None) - - try: - repo_path, manifest = library_manager.clone_repository(repo_url, namespace_override) - progress.update(task, completed=True) - - except TemplateAuthError as e: - progress.stop() - console.print(f"\n[red]❌ {e}[/red]") - raise typer.Exit(3) - - except TemplateNetworkError as e: - progress.stop() - console.print(f"\n[red]❌ {e}[/red]") - raise typer.Exit(4) - - except Exception as e: - progress.stop() - console.print(f"\n[red]❌ Failed to clone repository: {e}[/red]") - raise typer.Exit(1) - - console.print("[green]✓ Repository cloned[/green]\n") - - # Validate manifest - warnings = validate_manifest_size(repo_path / "templatekit.yaml", len(manifest.templates)) - for warning in warnings: - console.print(warning) - - dep_errors = validate_dependencies(manifest.templates) - if dep_errors: - console.print("[red]❌ Manifest validation errors:[/red]") - for error in dep_errors: - console.print(f" - {error}") - raise typer.Exit(5) - - # Detect IDEs - if installation_scope == InstallationScope.PROJECT: - try: - project_root = find_project_root() - except Exception: - console.print("[red]Error: Could not detect project root. Ensure you're in a project directory.[/red]") - raise typer.Exit(1) - else: - project_root = None - - detector = get_detector() - detected_tool_instances = detector.detect_installed_tools() if project_root else [] - detected_tools = [tool.tool_type for tool in detected_tool_instances] - - if not detected_tools and installation_scope == InstallationScope.PROJECT: - console.print("[yellow]⚠️ No AI coding tools detected in project.[/yellow]") - console.print("Templates will be installed but may not be accessible until IDE is configured.") - - if not detected_tools and installation_scope == InstallationScope.GLOBAL: - # For global, use a default set - detected_tools = [AIToolType.CURSOR, AIToolType.CLAUDE, AIToolType.WINSURF, AIToolType.COPILOT] - - # Install templates - console.print(f"[bold]Installing {len(manifest.templates)} templates...[/bold]\n") - - # Initialize tracker - if installation_scope == InstallationScope.PROJECT: - if project_root is None: - console.print("[red]Error: Project root not found[/red]") - raise typer.Exit(1) - tracker = TemplateInstallationTracker.for_project(project_root) - else: - tracker = TemplateInstallationTracker.for_global() - - installed_count = 0 - skipped_count = 0 - failed_count = 0 - - for template in manifest.templates: - template_display_name = f"{namespace}.{template.name}" - - try: - console.print(f"Installing [cyan]{template_display_name}[/cyan]...", end=" ") - - # Get template file - template_file = template.files[0] # Use first file for now - source_file = repo_path / template_file.path - content = source_file.read_text(encoding="utf-8") - - # Calculate checksum - checksum = sha256_string(content) - - # Install for each detected IDE - for ide_type in detected_tools: - # Get IDE-specific paths - if installation_scope == InstallationScope.PROJECT: - tool = detector.get_tool_by_type(ide_type) - if tool is None or project_root is None: - continue - - ide_base_path = tool.get_project_instructions_directory(project_root) - extension = tool.get_instruction_file_extension().lstrip(".") - else: - # Global installation - data_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) - global_base = data_dir / "global-templates" / ide_type.value - global_base.mkdir(parents=True, exist_ok=True) - ide_base_path = global_base - extension = "md" # Default - - # Get install path with namespace - install_path = get_install_path(namespace, template.name, ide_base_path, extension) - - # Check for conflicts - if install_path.exists() and not force: - console.print("[yellow]⚠️ (already exists, skipping)[/yellow]") - skipped_count += 1 - continue - - # Create parent directory - install_path.parent.mkdir(parents=True, exist_ok=True) - - # Write template file - install_path.write_text(content, encoding="utf-8") - - # Create installation record - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - template_name=template.name, - source_repo=manifest.name, - source_version=manifest.version, - namespace=namespace, - installed_path=str(install_path), - scope=installation_scope, - installed_at=datetime.now(), - checksum=checksum, - ide_type=ide_type, - ) - - tracker.add_installation(record) - - console.print("[green]✓[/green]") - installed_count += 1 - - except Exception as e: - console.print(f"[red]✗ {e}[/red]") - failed_count += 1 - - # Display summary - console.print() - table = Table(title="Installation Summary") - table.add_column("Status", style="cyan") - table.add_column("Count", style="magenta") - table.add_column("Templates", style="green") - - if installed_count > 0: - template_names = ", ".join([t.name for t in manifest.templates[:3]]) - if len(manifest.templates) > 3: - template_names += f", ... ({len(manifest.templates)} total)" - table.add_row("✓ Installed", str(installed_count), template_names) - - if skipped_count > 0: - table.add_row("⊘ Skipped", str(skipped_count), "(already exists)") - - if failed_count > 0: - table.add_row("✗ Failed", str(failed_count), "(see errors above)") - - console.print(table) - - # Show available commands - if installed_count > 0: - console.print("\n[bold]Commands available:[/bold]") - for template in manifest.templates[:5]: - console.print(f" /{namespace}.{template.name}") - if len(manifest.templates) > 5: - console.print(f" ... and {len(manifest.templates) - 5} more") - - console.print("\n[green]✓ Installation complete[/green]") - - except typer.Exit: - raise - except KeyboardInterrupt: - console.print("\n[yellow]Installation cancelled by user[/yellow]") - raise typer.Exit(130) - except Exception as e: - console.print(f"\n[red]Unexpected error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_list.py b/devsync/cli/template_list.py deleted file mode 100644 index 873c9e2..0000000 --- a/devsync/cli/template_list.py +++ /dev/null @@ -1,172 +0,0 @@ -"""Template list command.""" - -from typing import Optional - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.storage.template_tracker import TemplateInstallationTracker -from devsync.utils.project import find_project_root - -console = Console() - - -def list_command( - scope: str = typer.Option( - "all", - "--scope", - "-s", - help="Which installations to list (project, global, all)", - ), - repo: Optional[str] = typer.Option( - None, - "--repo", - "-r", - help="Filter by repository name", - ), - format_type: str = typer.Option( - "table", - "--format", - "-f", - help="Output format (table, json, simple)", - ), - verbose: bool = typer.Option( - False, - "--verbose", - "-v", - help="Show detailed information", - ), -) -> None: - """ - List installed templates. - - Example: - devsync template list - devsync template list --scope project - devsync template list --repo acme-templates - devsync template list --format json - """ - try: - # Validate scope - if scope not in ["project", "global", "all"]: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project', 'global', or 'all'[/red]") - raise typer.Exit(1) - - # Validate format - if format_type not in ["table", "json", "simple"]: - console.print(f"[red]Error: Invalid format '{format_type}'. Must be 'table', 'json', or 'simple'[/red]") - raise typer.Exit(1) - - # Load installation records - project_records: list = [] - global_records: list = [] - - if scope in ["project", "all"]: - try: - project_root = find_project_root() - if project_root: - tracker = TemplateInstallationTracker.for_project(project_root) - project_records = tracker.load_installation_records() - except Exception: - if scope == "project": - console.print("[yellow]⚠️ Not in a project directory[/yellow]") - raise typer.Exit(1) - - if scope in ["global", "all"]: - tracker = TemplateInstallationTracker.for_global() - global_records = tracker.load_installation_records() - - # Combine records - all_records = [] - if project_records: - all_records.extend(project_records) - if global_records: - all_records.extend(global_records) - - # Filter by repository if specified - if repo: - all_records = [r for r in all_records if r.source_repo == repo or r.namespace == repo] - - # Check if empty - if not all_records: - if repo: - console.print(f"[yellow]No templates installed from repository '{repo}'[/yellow]") - else: - console.print("[yellow]No templates installed.[/yellow]") - console.print("\nTo install templates:") - console.print(" devsync template install ") - raise typer.Exit(0) - - # Output based on format - if format_type == "json": - - output = { - "installations": [r.to_dict() for r in all_records], - "count": len(all_records), - "repositories": len(set(r.source_repo for r in all_records)), - } - console.print_json(data=output) - - elif format_type == "simple": - for record in all_records: - console.print(f"{record.namespace}.{record.template_name}") - - else: # table format - # Group by repository - repos: dict = {} - for record in all_records: - repo_key = record.source_repo - if repo_key not in repos: - repos[repo_key] = { - "version": record.source_version, - "namespace": record.namespace, - "records": [], - } - repos[repo_key]["records"].append(record) - - # Display each repository - for repo_name, repo_data in repos.items(): - console.print(f"\n[bold]Repository: {repo_name}[/bold] (v{repo_data['version']})") - console.print(f"[dim]Namespace: {repo_data['namespace']}[/dim]\n") - - table = Table() - table.add_column("Template", style="cyan") - table.add_column("IDE", style="green") - table.add_column("Scope", style="yellow") - table.add_column("Installed", style="magenta") - - if verbose: - table.add_column("Path", style="dim") - table.add_column("Checksum", style="dim") - - for record in repo_data["records"]: - installed_date = record.installed_at.strftime("%Y-%m-%d") - row = [ - record.template_name, - record.ide_type.value, - record.scope.value, - installed_date, - ] - - if verbose: - row.append(str(record.installed_path)) - row.append(record.checksum[:8] + "...") - - table.add_row(*row) - - console.print(table) - - # Summary - total_repos = len(repos) - total_templates = len(all_records) - console.print(f"\n[bold]Total:[/bold] {total_templates} templates from {total_repos} repository(ies)") - - except typer.Exit: - raise - except KeyboardInterrupt: - console.print("\n[yellow]Cancelled by user[/yellow]") - raise typer.Exit(130) - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_uninstall.py b/devsync/cli/template_uninstall.py deleted file mode 100644 index 7ff9465..0000000 --- a/devsync/cli/template_uninstall.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Template uninstall command.""" - -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.prompt import Confirm - -from devsync.storage.template_library import TemplateLibraryManager -from devsync.storage.template_tracker import TemplateInstallationTracker -from devsync.utils.project import find_project_root - -console = Console() - - -def uninstall_command( - repo_name: str = typer.Argument(..., help="Repository name or namespace to uninstall"), - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Which installation to remove (project or global)", - ), - template: Optional[str] = typer.Option( - None, - "--template", - "-t", - help="Uninstall specific template (not entire repository)", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Skip confirmation prompt", - ), - keep_files: bool = typer.Option( - False, - "--keep-files", - "-k", - help="Remove from tracking but keep files on disk", - ), -) -> None: - """ - Remove installed templates. - - Example: - devsync template uninstall acme-templates - devsync template uninstall acme-templates --force - devsync template uninstall acme-templates --template test-command - devsync template uninstall acme-templates --keep-files - """ - try: - # Validate scope - if scope not in ["project", "global"]: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project' or 'global'[/red]") - raise typer.Exit(1) - - # Get tracker - if scope == "project": - try: - project_root = find_project_root() - if not project_root: - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - except Exception: - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - tracker = TemplateInstallationTracker.for_project(project_root) - else: - tracker = TemplateInstallationTracker.for_global() - - # Load records - all_records = tracker.load_installation_records() - - # Filter by repository name or namespace - repo_records = [r for r in all_records if r.source_repo == repo_name or r.namespace == repo_name] - - if not repo_records: - console.print(f"[red]Error: Repository '{repo_name}' not found in {scope} installations[/red]") - console.print("\nInstalled repositories:") - repos = set(f"{r.source_repo} ({r.namespace})" for r in all_records) - for repo in sorted(repos): - console.print(f" - {repo}") - raise typer.Exit(1) - - # Filter by specific template if requested - if template: - repo_records = [r for r in repo_records if r.template_name == template] - if not repo_records: - console.print(f"[red]Error: Template '{template}' not found in repository '{repo_name}'[/red]") - raise typer.Exit(1) - - # Show what will be removed - console.print("\n[bold]The following templates will be removed:[/bold]") - for record in repo_records: - console.print(f" - {record.namespace}.{record.template_name} ({record.ide_type.value})") - - # Confirm unless --force - if not force: - confirm_msg = f"Remove {len(repo_records)} template(s) from {repo_name}?" - if not Confirm.ask(confirm_msg, default=False): - console.print("[yellow]Uninstall cancelled[/yellow]") - raise typer.Exit(0) - - # Remove templates - removed_count = 0 - for record in repo_records: - console.print(f"Removing [cyan]{record.namespace}.{record.template_name}[/cyan]...", end=" ") - - # Delete file if not keeping - if not keep_files: - try: - file_path = Path(record.installed_path) - if file_path.exists(): - file_path.unlink() - except Exception as e: - console.print(f"[yellow]⚠️ (failed to delete file: {e})[/yellow]") - continue - - # Remove from tracking - tracker.remove_installation(record.id) - removed_count += 1 - console.print("[green]✓[/green]") - - # Clean up library if removing entire repository and no templates remain - if not template: - remaining = tracker.get_installations_by_namespace(repo_records[0].namespace) - if not remaining: - try: - library_manager = TemplateLibraryManager() - library_manager.remove_repository(repo_records[0].namespace) - console.print(f"\n[dim]Removed repository from library: {repo_records[0].namespace}[/dim]") - except Exception: - pass # Library removal is optional - - console.print(f"\n[green]✓ Uninstalled {removed_count} template(s)[/green]") - - except typer.Exit: - raise - except KeyboardInterrupt: - console.print("\n[yellow]Cancelled by user[/yellow]") - raise typer.Exit(130) - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_update.py b/devsync/cli/template_update.py deleted file mode 100644 index 131d9e9..0000000 --- a/devsync/cli/template_update.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Template update command.""" - -from datetime import datetime -from pathlib import Path -from typing import Optional - -import typer -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn - -from devsync.core.checksum import sha256_string -from devsync.core.conflict_resolution import ( - apply_resolution, - detect_conflict, - prompt_conflict_resolution_template, -) -from devsync.core.models import ConflictResolution, ConflictType -from devsync.storage.template_library import TemplateLibraryManager -from devsync.storage.template_tracker import TemplateInstallationTracker -from devsync.utils.git_helpers import TemplateNetworkError, update_template_repo -from devsync.utils.project import find_project_root - -console = Console() - - -def update_command( - repo_name: Optional[str] = typer.Argument(None, help="Repository name to update (omit for --all)"), - all_repos: bool = typer.Option( - False, - "--all", - "-a", - help="Update all installed template repositories", - ), - scope: str = typer.Option( - "project", - "--scope", - "-s", - help="Which installations to update (project, global, both)", - ), - force: bool = typer.Option( - False, - "--force", - "-f", - help="Overwrite local changes without prompting", - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - "-n", - help="Show what would be updated without making changes", - ), -) -> None: - """ - Update installed templates to latest version. - - Example: - devsync template update acme-templates - devsync template update --all - devsync template update acme-templates --dry-run - devsync template update --all --force - """ - try: - # Validate arguments - if not repo_name and not all_repos: - console.print("[red]Error: Must specify repo-name or --all[/red]") - console.print("Usage: devsync template update or devsync template update --all") - raise typer.Exit(2) - - if scope not in ["project", "global", "both"]: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project', 'global', or 'both'[/red]") - raise typer.Exit(1) - - # Determine which trackers to use - trackers: list[tuple[str, TemplateInstallationTracker]] = [] - if scope in ["project", "both"]: - try: - project_root = find_project_root() - if project_root: - trackers.append(("project", TemplateInstallationTracker.for_project(project_root))) - except Exception: - if scope == "project": - console.print("[red]Error: Not in a project directory[/red]") - raise typer.Exit(1) - - if scope in ["global", "both"]: - trackers.append(("global", TemplateInstallationTracker.for_global())) - - # Collect repositories to update - repos_to_update: set[str] = set() - for scope_name, tracker in trackers: - records = tracker.load_installation_records() - if all_repos: - repos_to_update.update(r.namespace for r in records) - elif repo_name: - matching = [r for r in records if r.source_repo == repo_name or r.namespace == repo_name] - if matching: - repos_to_update.add(matching[0].namespace) - - if not repos_to_update: - if repo_name: - console.print(f"[yellow]Repository '{repo_name}' not found in {scope} installations[/yellow]") - else: - console.print(f"[yellow]No repositories found in {scope} installations[/yellow]") - raise typer.Exit(0) - - # Update each repository - library_manager = TemplateLibraryManager() - total_updated = 0 - - for namespace in sorted(repos_to_update): - console.print(f"\n[bold]Checking {namespace} for updates...[/bold]") - - # Get repository path - try: - repo_path, old_manifest = library_manager.get_template_repository(namespace) - except FileNotFoundError: - console.print("[yellow]⚠️ Repository not found in library, skipping[/yellow]") - continue - - # Check for updates - try: - with Progress( - SpinnerColumn(), TextColumn("[progress.description]{task.description}"), console=console - ) as progress: - task = progress.add_task("Fetching updates...", total=None) - has_updates = update_template_repo(repo_path) - progress.update(task, completed=True) - - if not has_updates: - console.print("[green]✓ Already up-to-date[/green]") - continue - - except TemplateNetworkError as e: - console.print(f"[red]❌ Failed to check for updates: {e}[/red]") - continue - - # Load new manifest - from devsync.core.template_manifest import load_manifest - - new_manifest = load_manifest(repo_path / "templatekit.yaml") - - console.print(f"[green]Found updates[/green] (v{old_manifest.version} → v{new_manifest.version})\n") - - if dry_run: - console.print("[dim]Dry run - no changes will be made[/dim]") - # Show what would be updated - for template in new_manifest.templates: - console.print(f" Would update: {namespace}.{template.name}") - continue - - # Update templates - updated_count = 0 - skipped_count = 0 - - for scope_name, tracker in trackers: - records = tracker.get_installations_by_namespace(namespace) - - for record in records: - # Find matching template in new manifest - matching_template = next( - (t for t in new_manifest.templates if t.name == record.template_name), None - ) - if not matching_template: - console.print( - f"[yellow]⚠️ Template {record.template_name} no longer in repository, skipping[/yellow]" - ) - continue - - # Read new template content - template_file = matching_template.files[0] - source_file = repo_path / template_file.path - new_content = source_file.read_text(encoding="utf-8") - - # Check for conflicts - installed_path = Path(record.installed_path) - conflict_type = detect_conflict(installed_path, new_content, record) - - if conflict_type != ConflictType.NONE and not force: - # Prompt for resolution - resolution = prompt_conflict_resolution_template( - f"{namespace}.{record.template_name}", conflict_type - ) - - if resolution == ConflictResolution.SKIP: - console.print(f"Skipping [cyan]{namespace}.{record.template_name}[/cyan]") - skipped_count += 1 - continue - - # Apply resolution - apply_resolution(installed_path, new_content, resolution) - else: - # Safe to update or force mode - console.print(f"Updating [cyan]{namespace}.{record.template_name}[/cyan]...", end=" ") - installed_path.write_text(new_content, encoding="utf-8") - - # Update installation record - record.source_version = new_manifest.version - record.checksum = sha256_string(new_content) - record.installed_at = datetime.now() - tracker.update_installation(record.id, record) - - console.print("[green]✓[/green]") - updated_count += 1 - - if updated_count > 0: - console.print(f"\n[green]✓ Updated {updated_count} template(s)[/green]") - total_updated += updated_count - if skipped_count > 0: - console.print(f"[yellow]Skipped {skipped_count} template(s) due to conflicts[/yellow]") - - if dry_run: - console.print("\n[dim]Dry run complete - no changes were made[/dim]") - elif total_updated > 0: - console.print(f"\n[green]✓ Total updated: {total_updated} template(s)[/green]") - else: - console.print("\n[yellow]No templates were updated[/yellow]") - - except typer.Exit: - raise - except KeyboardInterrupt: - console.print("\n[yellow]Cancelled by user[/yellow]") - raise typer.Exit(130) - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) diff --git a/devsync/cli/template_validate.py b/devsync/cli/template_validate.py deleted file mode 100644 index 8eb49a4..0000000 --- a/devsync/cli/template_validate.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Template validation command.""" - -from pathlib import Path - -import typer -from rich.console import Console -from rich.table import Table - -from devsync.core.checksum import calculate_file_checksum -from devsync.storage.template_library import TemplateLibraryManager -from devsync.storage.template_tracker import TemplateInstallationTracker -from devsync.utils.project import find_project_root - -console = Console() - - -class ValidationIssue: - """Represents a validation issue found during template validation.""" - - def __init__(self, severity: str, template: str, issue_type: str, description: str, remediation: str = ""): - """ - Initialize validation issue. - - Args: - severity: Issue severity (error, warning, info) - template: Template identifier - issue_type: Type of issue - description: Issue description - remediation: Suggested remediation - """ - self.severity = severity - self.template = template - self.issue_type = issue_type - self.description = description - self.remediation = remediation - - -def validate_command( - scope: str = typer.Option( - "all", - "--scope", - "-s", - help="Which installations to validate (project, global, all)", - ), - fix: bool = typer.Option( - False, - "--fix", - help="Attempt to fix issues automatically", - ), - verbose: bool = typer.Option( - False, - "--verbose", - "-v", - help="Show detailed information", - ), -) -> None: - """ - Validate installed templates for issues. - - Checks for: - - Tracking inconsistencies (installed but files missing) - - Missing files referenced in manifest - - Outdated versions (local vs remote) - - Broken template dependencies - - Local modifications (checksum mismatch) - - Example: - devsync template validate - devsync template validate --scope project - devsync template validate --fix - """ - try: - # Validate scope - if scope not in ["project", "global", "all"]: - console.print(f"[red]Error: Invalid scope '{scope}'. Must be 'project', 'global', or 'all'[/red]") - raise typer.Exit(1) - - issues: list[ValidationIssue] = [] - - # Validate project templates - if scope in ["project", "all"]: - try: - project_root = find_project_root() - if project_root: - console.print(f"\n[bold]Validating project templates[/bold] ({project_root})...") - project_issues = _validate_installations( - TemplateInstallationTracker.for_project(project_root), "project", verbose - ) - issues.extend(project_issues) - elif scope == "project": - console.print("[yellow]⚠️ Not in a project directory[/yellow]") - raise typer.Exit(1) - except Exception as e: - if scope == "project": - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) - - # Validate global templates - if scope in ["global", "all"]: - console.print("\n[bold]Validating global templates...[/bold]") - global_issues = _validate_installations(TemplateInstallationTracker.for_global(), "global", verbose) - issues.extend(global_issues) - - # Display results - _display_validation_results(issues, fix, verbose) - - except typer.Exit: - raise - except KeyboardInterrupt: - console.print("\n[yellow]Validation cancelled by user[/yellow]") - raise typer.Exit(130) - except Exception as e: - console.print(f"\n[red]Error: {e}[/red]") - raise typer.Exit(1) - - -def _validate_installations(tracker: TemplateInstallationTracker, scope: str, verbose: bool) -> list[ValidationIssue]: - """Validate all installations tracked by a tracker.""" - issues: list[ValidationIssue] = [] - records = tracker.load_installation_records() - - if not records: - console.print(f" [dim]No {scope} templates installed[/dim]") - return issues - - console.print(f" Found {len(records)} template(s)") - - for record in records: - template_id = f"{record.namespace}.{record.template_name}" - - # Check 1: File exists - installed_path = Path(record.installed_path) - if not installed_path.exists(): - issues.append( - ValidationIssue( - severity="error", - template=template_id, - issue_type="missing_file", - description=f"Installed file not found: {installed_path}", - remediation=( - f"Reinstall template with: " - f"devsync template install {record.source_repo} --template {record.template_name}" - ), - ) - ) - continue - - # Check 2: Local modifications (checksum mismatch) - try: - current_checksum = calculate_file_checksum(str(installed_path)) - if current_checksum != record.checksum: - issues.append( - ValidationIssue( - severity="warning", - template=template_id, - issue_type="modified", - description="Template has been modified locally", - remediation=f"Update to restore original: devsync template update {record.namespace}", - ) - ) - except Exception as e: - if verbose: - console.print(f" [dim]Could not verify checksum for {template_id}: {e}[/dim]") - - # Check 3: Outdated version - try: - library_manager = TemplateLibraryManager() - local_version = library_manager.get_repository_version(record.namespace) - if local_version and record.source_version and local_version != record.source_version: - issues.append( - ValidationIssue( - severity="info", - template=template_id, - issue_type="outdated", - description=f"Newer version available ({record.source_version} -> {local_version})", - remediation=f"Update with: devsync template update {record.namespace}", - ) - ) - except Exception as e: - if verbose: - console.print(f" [dim]Could not check version for {template_id}: {e}[/dim]") - - return issues - - -def _display_validation_results(issues: list[ValidationIssue], fix: bool, verbose: bool) -> None: - """Display validation results in a formatted table.""" - if not issues: - console.print("\n[green]✓ All templates are valid![/green]") - return - - # Group by severity - errors = [i for i in issues if i.severity == "error"] - warnings = [i for i in issues if i.severity == "warning"] - info = [i for i in issues if i.severity == "info"] - - console.print("\n[bold]Validation Summary:[/bold]") - if errors: - console.print(f" [red]✗ {len(errors)} error(s)[/red]") - if warnings: - console.print(f" [yellow]⚠ {len(warnings)} warning(s)[/yellow]") - if info: - console.print(f" [blue]ℹ {len(info)} info[/blue]") - - # Display issues table - if verbose or errors: - table = Table(title="\nValidation Issues") - table.add_column("Severity", style="bold") - table.add_column("Template") - table.add_column("Issue Type") - table.add_column("Description") - table.add_column("Remediation") - - for issue in errors + warnings + info: - severity_color = {"error": "red", "warning": "yellow", "info": "blue"}[issue.severity] - severity_symbol = {"error": "✗", "warning": "⚠", "info": "ℹ"}[issue.severity] - - table.add_row( - f"[{severity_color}]{severity_symbol} {issue.severity.upper()}[/{severity_color}]", - issue.template, - issue.issue_type, - issue.description, - issue.remediation, - ) - - console.print(table) - - if fix: - console.print("\n[yellow]⚠️ Auto-fix is not yet implemented[/yellow]") - console.print("Please use the suggested remediation commands above") - - # Exit with error code if there are errors - if errors: - raise typer.Exit(1) diff --git a/devsync/cli/update.py b/devsync/cli/update.py deleted file mode 100644 index 667ca9d..0000000 --- a/devsync/cli/update.py +++ /dev/null @@ -1,309 +0,0 @@ -"""Update command for refreshing library instructions.""" - -import shutil -from datetime import datetime -from pathlib import Path -from typing import Optional - -import typer -from git import Repo -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn - -from devsync.core.checksum import calculate_file_checksum -from devsync.core.git_operations import GitOperations, RepositoryOperationError -from devsync.core.models import LibraryInstruction, RefType -from devsync.core.repository import RepositoryParser -from devsync.storage.library import LibraryManager -from devsync.storage.tracker import InstallationTracker -from devsync.utils.project import find_project_root -from devsync.utils.ui import print_error, print_info, print_success - -console = Console() - -app = typer.Typer() - - -def update_repository( - namespace: Optional[str] = None, - all_repos: bool = False, -) -> int: - """ - Update instructions from their source repositories. - - Only updates mutable references (branches). Tags and commits are immutable and will be skipped. - - Args: - namespace: Specific repository namespace to update - all_repos: Update all repositories - - Returns: - Exit code (0 = success) - """ - library = LibraryManager() - tracker = InstallationTracker() - - if not all_repos and not namespace: - print_error("Must specify either --namespace or --all") - return 1 - - # Get repositories to update - if all_repos: - repositories = library.list_repositories() - if not repositories: - print_info("No repositories in library to update") - return 0 - else: - assert namespace is not None, "namespace should not be None here" - repo = library.get_repository(namespace) - if not repo: - print_error(f"Repository not found: {namespace}") - print_info("Use 'devsync list library' to see available repositories") - return 1 - repositories = [repo] - - console.print(f"\n[bold]Updating {len(repositories)} repository(ies)...[/bold]\n") - - updated_count = 0 - skipped_count = 0 - error_count = 0 - - for repo in repositories: - # Extract ref info from namespace - ref, ref_type = _extract_ref_from_namespace(repo.namespace) - - # Skip immutable refs (tags and commits) - if ref and ref_type in (RefType.TAG, RefType.COMMIT): - ref_type_name = "tag" if ref_type == RefType.TAG else "commit" - console.print( - f"[yellow]⊘ Skipped:[/yellow] {repo.name} " f"({ref_type_name} [cyan]{ref}[/cyan] is immutable)" - ) - skipped_count += 1 - continue - - # Update branch-based or non-versioned repositories - console.print(f"[cyan]Updating:[/cyan] {repo.name} ", end="") - if ref: - console.print(f"(branch [green]{ref}[/green])") - else: - console.print("(default branch)") - - try: - # Get the repository directory - repo_dir = library.library_dir / repo.namespace - - if not repo_dir.exists(): - print_error(f" Repository directory not found: {repo_dir}") - error_count += 1 - continue - - # Check if it's a git repository (skip local non-git repos) - git_dir = repo_dir / ".git" - if not git_dir.exists(): - console.print(" [yellow]⊘ Skipped:[/yellow] Not a Git repository (local source)") - skipped_count += 1 - continue - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - transient=True, - ) as progress: - task = progress.add_task(" Checking for updates...", total=None) - - # Open repository and check for updates - git_repo = Repo(repo_dir) - branch_name = ref if ref else git_repo.active_branch.name - - # Check if updates are available - has_updates = GitOperations.check_for_updates(git_repo, branch_name) - - if not has_updates: - console.print(" [green]✓[/green] Already up to date") - continue - - # Pull updates - progress.update(task, description=" Pulling updates...") - result = GitOperations.pull_repository_updates(git_repo, branch_name) - - if not result.get("success"): - error_type = result.get("error", "unknown") - if error_type == "local_modifications": - console.print(f" [red]✗[/red] {result.get('message')}") - elif error_type == "conflict": - console.print(" [red]✗[/red] Merge conflict detected") - else: - console.print(f" [red]✗[/red] Update failed: {result.get('message')}") - error_count += 1 - continue - - # Re-parse repository to update library index - parser = RepositoryParser(repo_dir) - repository = parser.parse() - - # Update library instructions - library_instructions = [] - instructions_dir = repo_dir / "instructions" - - for instruction in repository.instructions: - source_file = repo_dir / instruction.file_path - if not source_file.exists(): - continue - - dest_file = instructions_dir / f"{instruction.name}.md" - if source_file != dest_file: - shutil.copy2(source_file, dest_file) - - checksum = calculate_file_checksum(str(dest_file)) - - lib_inst = LibraryInstruction( - id=f"{repo.namespace}/{instruction.name}", - name=instruction.name, - description=instruction.description, - repo_namespace=repo.namespace, - repo_url=repo.url, - repo_name=repo.name, - author=repository.metadata.get("author", "Unknown"), - version=repository.metadata.get("version", "1.0.0"), - file_path=str(dest_file), - tags=instruction.tags, - downloaded_at=datetime.now(), - checksum=checksum, - ) - library_instructions.append(lib_inst) - - # Update library index - library.add_repository( - repo_name=repo.name, - repo_description=repository.metadata.get("description", ""), - repo_url=repo.url, - repo_author=repository.metadata.get("author", "Unknown"), - repo_version=repository.metadata.get("version", "1.0.0"), - instructions=library_instructions, - alias=repo.alias, - ) - - # Update installed instructions - _update_installed_instructions(repo.namespace, library_instructions, tracker) - - console.print(f" [green]✓[/green] Updated successfully ({len(library_instructions)} instructions)") - updated_count += 1 - - except RepositoryOperationError as e: - console.print(f" [red]✗[/red] Failed: {e}") - error_count += 1 - except Exception as e: - console.print(f" [red]✗[/red] Unexpected error: {e}") - error_count += 1 - - # Summary - console.print() - if updated_count > 0: - print_success(f"✓ Updated: {updated_count} repository(ies)") - if skipped_count > 0: - print_info(f"⊘ Skipped: {skipped_count} immutable reference(s)") - if error_count > 0: - print_error(f"✗ Failed: {error_count} repository(ies)") - - return 0 if error_count == 0 else 1 - - -def _extract_ref_from_namespace(namespace: str) -> tuple[Optional[str], Optional[RefType]]: - """Extract Git reference from versioned namespace.""" - if "@" not in namespace: - return (None, None) - - ref = namespace.split("@", 1)[1] - - import re - - # Tags typically start with 'v' followed by numbers - if re.match(r"^v?\d+\.\d+", ref): - return (ref, RefType.TAG) - # Commit hashes are hex strings - elif re.match(r"^[0-9a-f]{7,40}$", ref): - return (ref, RefType.COMMIT) - # Everything else is likely a branch - else: - return (ref, RefType.BRANCH) - - -def _update_installed_instructions( - repo_namespace: str, library_instructions: list[LibraryInstruction], tracker: InstallationTracker -) -> None: - """Update files for installed instructions from this repository.""" - project_root = find_project_root() - - # Get all installed instructions from this repository - all_records = tracker.get_installed_instructions(project_root=project_root) - repo_records = [r for r in all_records if repo_namespace in r.source_repo or repo_namespace in r.instruction_name] - - if not repo_records: - return - - # Update each installed instruction file - for record in repo_records: - # Find matching library instruction - matching_inst = None - for lib_inst in library_instructions: - if lib_inst.name in record.instruction_name or record.instruction_name in lib_inst.id: - matching_inst = lib_inst - break - - if not matching_inst: - continue - - # Update the installed file - try: - source_path = Path(matching_inst.file_path) - if not source_path.exists(): - continue - - installed_path = Path(record.installed_path) - # Handle relative paths for project-scoped installations - if not installed_path.is_absolute() and project_root: - installed_path = project_root / installed_path - - if installed_path.exists(): - # Read new content and write to installed location - content = source_path.read_text(encoding="utf-8") - installed_path.write_text(content, encoding="utf-8") - except Exception: - # Silently skip if update fails (file might have been manually removed) - continue - - -@app.command(name="update") -def update_command( - namespace: Optional[str] = typer.Option( - None, - "--namespace", - "-n", - help="Repository namespace to update", - ), - all_repos: bool = typer.Option( - False, - "--all", - "-a", - help="Update all repositories in library", - ), -) -> None: - """ - Update downloaded instructions to their latest versions. - - This re-downloads instructions from their source repositories, - ensuring you have the latest versions in your library. - - Examples: - # Update a specific repository - devsync update --namespace github.com_company_instructions - - # Update all repositories - devsync update --all - - # List repositories to find namespace - devsync list library - """ - exit_code = update_repository(namespace=namespace, all_repos=all_repos) - raise typer.Exit(code=exit_code) diff --git a/devsync/core/template_manifest.py b/devsync/core/template_manifest.py deleted file mode 100644 index 3b42dad..0000000 --- a/devsync/core/template_manifest.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Template manifest parsing and validation.""" - -from pathlib import Path -from typing import Any - -import yaml - -from devsync.core.models import TemplateBundle, TemplateDefinition, TemplateFile, TemplateManifest - - -class TemplateManifestError(Exception): - """Raised when template manifest is invalid.""" - - pass - - -def load_manifest(manifest_path: Path) -> TemplateManifest: - """ - Load and validate template manifest from YAML file. - - Args: - manifest_path: Path to templatekit.yaml - - Returns: - Parsed and validated TemplateManifest - - Raises: - TemplateManifestError: If manifest is invalid or missing - FileNotFoundError: If manifest file doesn't exist - - Example: - >>> from pathlib import Path - >>> manifest = load_manifest(Path("repo/templatekit.yaml")) - >>> manifest.name - 'My Templates' - """ - if not manifest_path.exists(): - raise FileNotFoundError(f"Manifest not found: {manifest_path}") - - try: - with open(manifest_path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - except yaml.YAMLError as e: - raise TemplateManifestError(f"Invalid YAML in manifest: {e}") from e - - if not data: - raise TemplateManifestError("Manifest file is empty") - - try: - return parse_manifest(data, manifest_path) - except (ValueError, KeyError) as e: - raise TemplateManifestError(f"Invalid manifest structure: {e}") from e - - -def parse_manifest(data: dict[str, Any], manifest_path: Path) -> TemplateManifest: - """ - Parse manifest data into TemplateManifest object. - - Args: - data: Parsed YAML data - manifest_path: Path to manifest (for validation context) - - Returns: - TemplateManifest object - - Raises: - ValueError: If required fields are missing or invalid - """ - # Required fields - if "name" not in data: - raise ValueError("Manifest missing required field: name") - if "description" not in data: - raise ValueError("Manifest missing required field: description") - if "version" not in data: - raise ValueError("Manifest missing required field: version") - if "templates" not in data: - raise ValueError("Manifest missing required field: templates") - - # Parse templates - templates = [] - for template_data in data["templates"]: - template = parse_template(template_data, manifest_path) - templates.append(template) - - # Parse bundles (optional) - bundles = [] - if "bundles" in data: - for bundle_data in data["bundles"]: - bundle = parse_bundle(bundle_data) - bundles.append(bundle) - - # Validate bundle references - template_names = {t.name for t in templates} - for bundle in bundles: - for template_ref in bundle.template_refs: - if template_ref not in template_names: - raise ValueError(f"Bundle '{bundle.name}' references non-existent template '{template_ref}'") - - return TemplateManifest( - name=data["name"], - description=data["description"], - version=data["version"], - author=data.get("author"), - templates=templates, - bundles=bundles, - ) - - -def parse_template(data: dict[str, Any], manifest_path: Path) -> TemplateDefinition: - """ - Parse template definition from manifest data. - - Args: - data: Template data from manifest - manifest_path: Path to manifest (for file validation) - - Returns: - TemplateDefinition object - - Raises: - ValueError: If template data is invalid - """ - if "name" not in data: - raise ValueError("Template missing required field: name") - if "description" not in data: - raise ValueError(f"Template '{data.get('name', 'unknown')}' missing required field: description") - if "files" not in data or not data["files"]: - raise ValueError(f"Template '{data['name']}' must have at least one file") - - # Parse files - files = [] - for file_data in data["files"]: - if isinstance(file_data, str): - # Simple format: just path string - file_obj = TemplateFile(path=file_data, ide="all") - elif isinstance(file_data, dict): - # Detailed format with path and ide - if "path" not in file_data: - raise ValueError(f"Template '{data['name']}' file entry missing 'path'") - file_obj = TemplateFile(path=file_data["path"], ide=file_data.get("ide", "all")) - else: - raise ValueError(f"Invalid file entry in template '{data['name']}': {file_data}") - - # Validate file exists in repository - repo_path = manifest_path.parent - file_path = repo_path / file_obj.path - if not file_path.exists(): - raise ValueError(f"Template '{data['name']}' references non-existent file: {file_obj.path}") - - files.append(file_obj) - - return TemplateDefinition( - name=data["name"], - description=data["description"], - files=files, - tags=data.get("tags", []), - dependencies=data.get("dependencies", []), - ) - - -def parse_bundle(data: dict[str, Any]) -> TemplateBundle: - """ - Parse bundle definition from manifest data. - - Args: - data: Bundle data from manifest - - Returns: - TemplateBundle object - - Raises: - ValueError: If bundle data is invalid - """ - if "name" not in data: - raise ValueError("Bundle missing required field: name") - if "description" not in data: - raise ValueError(f"Bundle '{data.get('name', 'unknown')}' missing required field: description") - if "templates" not in data or not data["templates"]: - raise ValueError(f"Bundle '{data['name']}' must reference at least one template") - - return TemplateBundle( - name=data["name"], - description=data["description"], - template_refs=data["templates"], - tags=data.get("tags", []), - ) - - -def validate_manifest_size(manifest_path: Path, template_count: int, soft_limit_templates: int = 100) -> list[str]: - """ - Check manifest against soft limits and return warnings. - - Args: - manifest_path: Path to manifest - template_count: Number of templates in manifest - soft_limit_templates: Soft limit for template count - - Returns: - List of warning messages (empty if no warnings) - - Example: - >>> warnings = validate_manifest_size(Path("repo/templatekit.yaml"), 150) - >>> len(warnings) > 0 - True - """ - warnings = [] - - # Check template count - if template_count > soft_limit_templates: - warnings.append( - f"⚠️ Repository contains {template_count} templates " - f"(soft limit: {soft_limit_templates}). " - f"Large repositories may take longer to install." - ) - - # Check repository size (approximate based on manifest directory) - repo_path = manifest_path.parent - total_size = 0 - for file_path in repo_path.rglob("*"): - if file_path.is_file(): - total_size += file_path.stat().st_size - - size_mb = total_size / (1024 * 1024) - soft_limit_mb = 50 - - if size_mb > soft_limit_mb: - warnings.append( - f"⚠️ Repository size is {size_mb:.1f}MB " - f"(soft limit: {soft_limit_mb}MB). " - f"Installation may take longer." - ) - - return warnings - - -def validate_dependencies(templates: list[TemplateDefinition]) -> list[str]: - """ - Validate template dependencies for circular references. - - Args: - templates: List of template definitions - - Returns: - List of error messages (empty if valid) - - Example: - >>> errors = validate_dependencies(templates) - >>> len(errors) == 0 - True - """ - errors = [] - template_names = {t.name for t in templates} - - # Build dependency graph - dependencies = {t.name: set(t.dependencies) for t in templates} - - # Check for circular dependencies using DFS - def has_cycle(node: str, visited: set[str], rec_stack: set[str]) -> bool: - visited.add(node) - rec_stack.add(node) - - for neighbor in dependencies.get(node, []): - if neighbor not in visited: - if has_cycle(neighbor, visited, rec_stack): - return True - elif neighbor in rec_stack: - return True - - rec_stack.remove(node) - return False - - visited: set[str] = set() - for template in templates: - if template.name not in visited: - if has_cycle(template.name, visited, set()): - errors.append(f"Circular dependency detected involving template '{template.name}'") - - # Check for non-existent dependencies - for dep in template.dependencies: - if dep not in template_names: - errors.append(f"Template '{template.name}' depends on non-existent template '{dep}'") - - return errors diff --git a/devsync/storage/library.py b/devsync/storage/library.py deleted file mode 100644 index f4cf1c8..0000000 --- a/devsync/storage/library.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Library management for downloaded instructions.""" - -import json -import shutil -from datetime import datetime -from pathlib import Path -from typing import Optional - -from devsync.core.models import LibraryInstruction, LibraryRepository -from devsync.utils.paths import get_library_dir - - -class LibraryManager: - """ - Manages the local library of downloaded instructions. - - The library structure: - ~/.devsync/ - ├── library/ - │ ├── repo-namespace-1/ - │ │ └── instructions/ - │ │ └── instruction.md - │ └── repo-namespace-2/ - │ └── instructions/ - └── library.json (index of all repositories) - """ - - def __init__(self, library_dir: Optional[Path] = None): - """ - Initialize library manager. - - Args: - library_dir: Path to library directory (default: ~/.devsync/library) - """ - self.library_dir = library_dir or get_library_dir() - self.library_dir.mkdir(parents=True, exist_ok=True) - - self.index_file = self.library_dir.parent / "library.json" - - def get_repo_namespace(self, url: str, repo_name: str) -> str: - """ - Generate a unique namespace for a repository. - - Args: - url: Repository URL - repo_name: Repository name - - Returns: - Namespace string (e.g., 'github.com_company_instructions') - """ - # Parse URL to extract host and path - # For local paths, use the folder name - if url.startswith(("http://", "https://", "git@")): - # Extract domain and repo path - # https://github.com/company/instructions -> github.com_company_instructions - import re - - # Remove protocol - clean_url = re.sub(r"^(https?://|git@)", "", url) - # Remove .git suffix - clean_url = re.sub(r"\.git$", "", clean_url) - # Replace special chars with underscore - namespace = re.sub(r"[^a-zA-Z0-9]", "_", clean_url) - else: - # Local path - use folder name + sanitized path - path = Path(url).resolve() - namespace = f"local_{path.name}_{abs(hash(str(path))) % 100000}" - - return namespace - - def generate_alias(self, url: str, repo_name: str) -> str: - """ - Auto-generate a friendly alias from URL or repo name. - - Args: - url: Repository URL - repo_name: Repository name - - Returns: - Friendly alias (e.g., 'company-instructions' from github.com/company/instructions) - """ - import re - - if url.startswith(("http://", "https://")): - # Extract repo path from URL - # https://github.com/company/instructions -> company-instructions - match = re.search(r"/([^/]+)/([^/]+?)(?:\.git)?$", url) - if match: - org, repo = match.groups() - return f"{org}-{repo}".lower() - - # Fallback to sanitized repo name - return re.sub(r"[^a-z0-9-]", "-", repo_name.lower()).strip("-") - - def load_index(self) -> dict[str, LibraryRepository]: - """ - Load the library index. - - Returns: - Dictionary mapping namespace to LibraryRepository - """ - if not self.index_file.exists(): - return {} - - with open(self.index_file, "r", encoding="utf-8") as f: - data = json.load(f) - - return {namespace: LibraryRepository.from_dict(repo_data) for namespace, repo_data in data.items()} - - def save_index(self, repositories: dict[str, LibraryRepository]) -> None: - """ - Save the library index. - - Args: - repositories: Dictionary mapping namespace to LibraryRepository - """ - data = {namespace: repo.to_dict() for namespace, repo in repositories.items()} - - with open(self.index_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - def add_repository( - self, - repo_name: str, - repo_description: str, - repo_url: str, - repo_author: str, - repo_version: str, - instructions: list[LibraryInstruction], - alias: Optional[str] = None, - namespace: Optional[str] = None, - ) -> LibraryRepository: - """ - Add a repository to the library. - - Args: - repo_name: Repository display name - repo_description: Repository description - repo_url: Repository URL - repo_author: Repository author - repo_version: Repository version - instructions: List of instructions to add - alias: User-friendly alias (auto-generated if not provided) - namespace: Repository namespace (auto-generated if not provided) - - Returns: - Created LibraryRepository - """ - # Generate namespace if not provided - if namespace is None: - namespace = self.get_repo_namespace(repo_url, repo_name) - - # Auto-generate alias if not provided - if alias is None: - alias = self.generate_alias(repo_url, repo_name) - - # Create repository directory - repo_dir = self.library_dir / namespace - repo_dir.mkdir(parents=True, exist_ok=True) - - # Create instructions directory - instructions_dir = repo_dir / "instructions" - instructions_dir.mkdir(exist_ok=True) - - # Create repository object - library_repo = LibraryRepository( - namespace=namespace, - name=repo_name, - description=repo_description, - url=repo_url, - author=repo_author, - version=repo_version, - downloaded_at=datetime.now(), - alias=alias, - instructions=instructions, - ) - - # Update index - index = self.load_index() - index[namespace] = library_repo - self.save_index(index) - - return library_repo - - def remove_repository(self, namespace: str) -> bool: - """ - Remove a repository from the library. - - Args: - namespace: Repository namespace to remove - - Returns: - True if removed, False if not found - """ - # Load index - index = self.load_index() - - if namespace not in index: - return False - - # Remove from index - del index[namespace] - self.save_index(index) - - # Remove directory - repo_dir = self.library_dir / namespace - if repo_dir.exists(): - shutil.rmtree(repo_dir) - - return True - - def get_repository(self, namespace: str) -> Optional[LibraryRepository]: - """ - Get a repository by namespace. - - Args: - namespace: Repository namespace - - Returns: - LibraryRepository or None if not found - """ - index = self.load_index() - return index.get(namespace) - - def get_repository_by_url(self, url: str) -> Optional[LibraryRepository]: - """ - Find a repository by its source URL. - - Args: - url: Repository URL (will be normalized for comparison) - - Returns: - LibraryRepository or None if not found - """ - # Normalize path for comparison - if not url.startswith(("http://", "https://", "git@")): - url = str(Path(url).resolve()) - - index = self.load_index() - for repo in index.values(): - repo_url = repo.url - # Normalize repo URL for comparison - if not repo_url.startswith(("http://", "https://", "git@")): - repo_url = str(Path(repo_url).resolve()) - - if repo_url == url: - return repo - - return None - - def list_repositories(self) -> list[LibraryRepository]: - """ - List all repositories in the library. - - Returns: - List of LibraryRepository objects - """ - index = self.load_index() - return list(index.values()) - - def list_instructions(self) -> list[LibraryInstruction]: - """ - List all instructions across all repositories. - - Returns: - Flattened list of all LibraryInstruction objects - """ - instructions = [] - for repo in self.list_repositories(): - instructions.extend(repo.instructions) - return instructions - - def get_instruction(self, instruction_id: str) -> Optional[LibraryInstruction]: - """ - Get an instruction by ID. - - Args: - instruction_id: Instruction ID (namespace/name) - - Returns: - LibraryInstruction or None if not found - """ - for instruction in self.list_instructions(): - if instruction.id == instruction_id: - return instruction - return None - - def get_instructions_by_name(self, name: str) -> list[LibraryInstruction]: - """ - Get all instructions with a given name (may be multiple from different repos). - - Args: - name: Instruction name - - Returns: - List of LibraryInstruction objects with matching name - """ - return [inst for inst in self.list_instructions() if inst.name == name] - - def get_instructions_by_source_and_name(self, source_alias: str, name: str) -> list[LibraryInstruction]: - """ - Get instructions by source alias and name. - - Args: - source_alias: Source alias to filter by - name: Instruction name - - Returns: - List of LibraryInstruction objects matching source and name - """ - # Find repositories matching the source alias - matching_repos = [] - for repo in self.list_repositories(): - if repo.alias and repo.alias.lower() == source_alias.lower(): - matching_repos.append(repo) - - # Get instructions from matching repos - return [inst for repo in matching_repos for inst in repo.instructions if inst.name == name] - - def search_instructions( - self, - query: Optional[str] = None, - repo_namespace: Optional[str] = None, - tags: Optional[list[str]] = None, - ) -> list[LibraryInstruction]: - """ - Search instructions with filters. - - Args: - query: Search query (matches name or description) - repo_namespace: Filter by repository namespace - tags: Filter by tags (instruction must have at least one matching tag) - - Returns: - List of matching LibraryInstruction objects - """ - instructions = self.list_instructions() - - # Filter by query - if query: - query_lower = query.lower() - instructions = [ - inst - for inst in instructions - if query_lower in inst.name.lower() or query_lower in inst.description.lower() - ] - - # Filter by repo - if repo_namespace: - instructions = [inst for inst in instructions if inst.repo_namespace == repo_namespace] - - # Filter by tags - if tags: - instructions = [inst for inst in instructions if any(tag in inst.tags for tag in tags)] - - return instructions - - def get_instruction_file_path(self, instruction_id: str) -> Optional[Path]: - """ - Get the absolute path to an instruction file. - - Args: - instruction_id: Instruction ID (namespace/name) - - Returns: - Path to instruction file or None if not found - """ - instruction = self.get_instruction(instruction_id) - if not instruction: - return None - - return Path(instruction.file_path) - - def get_versioned_namespace(self, repo_identifier: str, ref: str) -> str: - """ - Generate filesystem-safe namespace for repo@ref. - - Args: - repo_identifier: Repository identifier (URL or name) - ref: Git reference (tag, branch, or commit) - - Returns: - Versioned namespace string (e.g., 'github.com_owner_repo@v1.0.0') - """ - import re - - # Get base namespace without version - base_namespace = self.get_repo_namespace(repo_identifier, "") - - # Sanitize ref for filesystem (replace special chars) - # Handle refs like 'feature/new-feature' or 'refs/tags/v1.0.0' - safe_ref = re.sub(r"[^a-zA-Z0-9._-]", "_", ref) - - return f"{base_namespace}@{safe_ref}" - - def list_repository_versions(self, repo_identifier: str) -> list[tuple[str, str]]: - """ - List all downloaded versions of a repository. - - Args: - repo_identifier: Repository identifier (URL or base namespace) - - Returns: - List of tuples (version_ref, full_namespace) for all versions found - """ - import re - - # Get base namespace pattern to match - base_namespace = self.get_repo_namespace(repo_identifier, "") - - # Load index - index = self.load_index() - - # Find all namespaces that match base pattern with version suffix - versions = [] - pattern = re.compile(rf"^{re.escape(base_namespace)}@(.+)$") - - for namespace in index.keys(): - match = pattern.match(namespace) - if match: - version_ref = match.group(1) - # Convert sanitized ref back (e.g., 'feature_new-feature' -> original might vary) - versions.append((version_ref, namespace)) - - # Also check if there's a non-versioned namespace (legacy support) - if base_namespace in index: - versions.append(("default", base_namespace)) - - return versions diff --git a/devsync/storage/template_library.py b/devsync/storage/template_library.py deleted file mode 100644 index a6b18a9..0000000 --- a/devsync/storage/template_library.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Template library management for downloaded template repositories.""" - -from pathlib import Path -from typing import Optional - -from devsync.core.models import TemplateManifest -from devsync.core.template_manifest import load_manifest -from devsync.utils.git_helpers import clone_template_repo, get_repo_version -from devsync.utils.namespace import derive_namespace -from devsync.utils.paths import _resolve_data_dir - - -class TemplateLibraryManager: - """Manages template repositories in local library (~/.devsync/templates/).""" - - def __init__(self, library_path: Optional[Path] = None): - """ - Initialize template library manager. - - Args: - library_path: Custom library path (default: ~/.devsync/templates/) - """ - if library_path is None: - data_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) - library_path = data_dir / "templates" - - self.library_path = library_path - self.library_path.mkdir(parents=True, exist_ok=True) - - def clone_repository( - self, repo_url: str, namespace_override: Optional[str] = None - ) -> tuple[Path, TemplateManifest]: - """ - Clone template repository to library. - - Args: - repo_url: Git repository URL - namespace_override: Optional namespace override - - Returns: - Tuple of (repository path, parsed manifest) - - Raises: - TemplateAuthError: If authentication fails - TemplateNetworkError: If network/repository unavailable - TemplateManifestError: If manifest is invalid - - Example: - >>> manager = TemplateLibraryManager() - >>> repo_path, manifest = manager.clone_repository( - ... "https://github.com/acme/templates" - ... ) - >>> manifest.name - 'ACME Templates' - """ - # Derive namespace - namespace = derive_namespace(repo_url, namespace_override) - - # Clone to library - destination = self.library_path / namespace - - # Remove existing directory if it exists - if destination.exists(): - import shutil - - shutil.rmtree(destination) - - clone_template_repo(repo_url, destination) - - # Load and validate manifest - manifest_path = destination / "templatekit.yaml" - manifest = load_manifest(manifest_path) - - return destination, manifest - - def get_template_repository(self, namespace: str) -> tuple[Path, TemplateManifest]: - """ - Get template repository from library by namespace. - - Args: - namespace: Repository namespace - - Returns: - Tuple of (repository path, parsed manifest) - - Raises: - FileNotFoundError: If repository not found in library - TemplateManifestError: If manifest is invalid - - Example: - >>> manager = TemplateLibraryManager() - >>> repo_path, manifest = manager.get_template_repository("acme-templates") - """ - repo_path = self.library_path / namespace - - if not repo_path.exists(): - raise FileNotFoundError( - f"Template repository '{namespace}' not found in library.\n" - f"Install it with: devsync template install " - ) - - manifest_path = repo_path / "templatekit.yaml" - manifest = load_manifest(manifest_path) - - return repo_path, manifest - - def list_available_templates(self, namespace: str) -> list[str]: - """ - List all available templates in a repository. - - Args: - namespace: Repository namespace - - Returns: - List of template names - - Raises: - FileNotFoundError: If repository not found - TemplateManifestError: If manifest is invalid - - Example: - >>> manager = TemplateLibraryManager() - >>> templates = manager.list_available_templates("acme-templates") - >>> "test-command" in templates - True - """ - _, manifest = self.get_template_repository(namespace) - return [template.name for template in manifest.templates] - - def get_repository_version(self, namespace: str) -> Optional[str]: - """ - Get version of repository in library. - - Args: - namespace: Repository namespace - - Returns: - Version string (tag or commit hash) or None if not found - - Example: - >>> manager = TemplateLibraryManager() - >>> version = manager.get_repository_version("acme-templates") - >>> version - 'v1.2.0' - """ - repo_path = self.library_path / namespace - - if not repo_path.exists(): - return None - - try: - return get_repo_version(repo_path) - except Exception: - return None - - def list_installed_repositories(self) -> list[str]: - """ - List all template repositories in library. - - Returns: - List of repository namespaces - - Example: - >>> manager = TemplateLibraryManager() - >>> repos = manager.list_installed_repositories() - >>> "acme-templates" in repos - True - """ - if not self.library_path.exists(): - return [] - - repositories = [] - for item in self.library_path.iterdir(): - if item.is_dir() and (item / "templatekit.yaml").exists(): - repositories.append(item.name) - - return sorted(repositories) - - def remove_repository(self, namespace: str) -> None: - """ - Remove template repository from library. - - Args: - namespace: Repository namespace - - Raises: - FileNotFoundError: If repository not found - - Example: - >>> manager = TemplateLibraryManager() - >>> manager.remove_repository("acme-templates") - """ - repo_path = self.library_path / namespace - - if not repo_path.exists(): - raise FileNotFoundError(f"Repository '{namespace}' not found in library") - - import shutil - - shutil.rmtree(repo_path) - - def get_template_file_path(self, namespace: str, template_name: str, file_path: str) -> Path: - """ - Get absolute path to a template file in repository. - - Args: - namespace: Repository namespace - template_name: Template name - file_path: Relative file path from manifest - - Returns: - Absolute path to template file - - Raises: - FileNotFoundError: If repository or file not found - - Example: - >>> manager = TemplateLibraryManager() - >>> path = manager.get_template_file_path( - ... "acme-templates", - ... "test-command", - ... "templates/test.md" - ... ) - """ - repo_path, _ = self.get_template_repository(namespace) - template_file = repo_path / file_path - - if not template_file.exists(): - raise FileNotFoundError(f"Template file not found: {file_path}") - - return template_file diff --git a/devsync/storage/template_tracker.py b/devsync/storage/template_tracker.py deleted file mode 100644 index 41d59c8..0000000 --- a/devsync/storage/template_tracker.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Installation tracking for templates.""" - -import json -from datetime import datetime -from pathlib import Path -from typing import Optional - -from devsync.core.models import TemplateInstallationRecord -from devsync.utils.paths import _resolve_data_dir - - -class TemplateInstallationTracker: - """Tracks installed templates in projects and globally.""" - - def __init__(self, tracking_file: Path): - """ - Initialize installation tracker. - - Args: - tracking_file: Path to installations JSON file - """ - self.tracking_file = tracking_file - - @classmethod - def for_project(cls, project_root: Path) -> "TemplateInstallationTracker": - """ - Create tracker for project-level installations. - - Args: - project_root: Project root directory - - Returns: - TemplateInstallationTracker instance - - Example: - >>> from pathlib import Path - >>> tracker = TemplateInstallationTracker.for_project(Path.cwd()) - """ - tracking_dir = _resolve_data_dir(project_root, ".devsync", [".instructionkit", ".ai-config-kit"]) - tracking_dir.mkdir(parents=True, exist_ok=True) - tracking_file = tracking_dir / "template-installations.json" - return cls(tracking_file) - - @classmethod - def for_global(cls) -> "TemplateInstallationTracker": - """ - Create tracker for global installations. - - Returns: - TemplateInstallationTracker instance - - Example: - >>> tracker = TemplateInstallationTracker.for_global() - """ - tracking_dir = _resolve_data_dir(Path.home(), ".devsync", [".instructionkit"]) - tracking_dir.mkdir(parents=True, exist_ok=True) - tracking_file = tracking_dir / "global-template-installations.json" - return cls(tracking_file) - - def load_installation_records(self) -> list[TemplateInstallationRecord]: - """ - Load installation records from JSON file. - - Returns: - List of installation records (empty if file doesn't exist) - - Example: - >>> tracker = TemplateInstallationTracker.for_project(Path.cwd()) - >>> records = tracker.load_installation_records() - >>> len(records) - 0 - """ - if not self.tracking_file.exists(): - return [] - - try: - with open(self.tracking_file, "r", encoding="utf-8") as f: - data = json.load(f) - - records = [] - for record_data in data.get("installations", []): - try: - record = TemplateInstallationRecord.from_dict(record_data) - records.append(record) - except (ValueError, KeyError) as e: - # Skip invalid records - print(f"Warning: Skipping invalid installation record: {e}") - continue - - return records - - except (json.JSONDecodeError, OSError) as e: - print(f"Warning: Failed to load installation records: {e}") - return [] - - def save_installation_records(self, records: list[TemplateInstallationRecord]) -> None: - """ - Save installation records to JSON file. - - Args: - records: List of installation records to save - - Example: - >>> tracker = TemplateInstallationTracker.for_project(Path.cwd()) - >>> tracker.save_installation_records([record]) - """ - # Ensure directory exists - self.tracking_file.parent.mkdir(parents=True, exist_ok=True) - - data = { - "installations": [record.to_dict() for record in records], - "last_updated": datetime.now().isoformat(), - "schema_version": "1.0", - } - - with open(self.tracking_file, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - def add_installation(self, record: TemplateInstallationRecord) -> None: - """ - Add a new installation record. - - Args: - record: Installation record to add - - Example: - >>> from devsync.core.models import AIToolType, InstallationScope - >>> from datetime import datetime - >>> record = TemplateInstallationRecord( - ... id="550e8400-e29b-41d4-a716-446655440000", - ... template_name="test-command", - ... source_repo="acme-templates", - ... source_version="1.0.0", - ... namespace="acme", - ... installed_path="/project/.cursor/rules/acme.test.md", - ... scope=InstallationScope.PROJECT, - ... installed_at=datetime.now(), - ... checksum="a" * 64, - ... ide_type=AIToolType.CURSOR - ... ) - >>> tracker.add_installation(record) - """ - records = self.load_installation_records() - records.append(record) - self.save_installation_records(records) - - def get_installation_by_id(self, installation_id: str) -> Optional[TemplateInstallationRecord]: - """ - Get installation record by ID. - - Args: - installation_id: Installation UUID - - Returns: - Installation record or None if not found - - Example: - >>> record = tracker.get_installation_by_id("550e8400-...") - """ - records = self.load_installation_records() - for record in records: - if record.id == installation_id: - return record - return None - - def get_installations_by_repo(self, source_repo: str) -> list[TemplateInstallationRecord]: - """ - Get all installations from a specific repository. - - Args: - source_repo: Repository name - - Returns: - List of installation records from that repository - - Example: - >>> records = tracker.get_installations_by_repo("acme-templates") - >>> len(records) - 3 - """ - records = self.load_installation_records() - return [r for r in records if r.source_repo == source_repo] - - def get_installations_by_namespace(self, namespace: str) -> list[TemplateInstallationRecord]: - """ - Get all installations from a specific namespace. - - Args: - namespace: Repository namespace - - Returns: - List of installation records with that namespace - - Example: - >>> records = tracker.get_installations_by_namespace("acme") - """ - records = self.load_installation_records() - return [r for r in records if r.namespace == namespace] - - def remove_installation(self, installation_id: str) -> bool: - """ - Remove an installation record. - - Args: - installation_id: Installation UUID - - Returns: - True if removed, False if not found - - Example: - >>> removed = tracker.remove_installation("550e8400-...") - >>> removed - True - """ - records = self.load_installation_records() - original_count = len(records) - - records = [r for r in records if r.id != installation_id] - - if len(records) < original_count: - self.save_installation_records(records) - return True - - return False - - def remove_installations_by_repo(self, source_repo: str) -> int: - """ - Remove all installations from a specific repository. - - Args: - source_repo: Repository name - - Returns: - Number of installations removed - - Example: - >>> count = tracker.remove_installations_by_repo("acme-templates") - >>> count - 3 - """ - records = self.load_installation_records() - original_count = len(records) - - records = [r for r in records if r.source_repo != source_repo] - removed_count = original_count - len(records) - - if removed_count > 0: - self.save_installation_records(records) - - return removed_count - - def update_installation(self, installation_id: str, updated_record: TemplateInstallationRecord) -> bool: - """ - Update an existing installation record. - - Args: - installation_id: Installation UUID - updated_record: New installation record - - Returns: - True if updated, False if not found - - Example: - >>> updated = tracker.update_installation("550e8400-...", new_record) - >>> updated - True - """ - records = self.load_installation_records() - - for i, record in enumerate(records): - if record.id == installation_id: - records[i] = updated_record - self.save_installation_records(records) - return True - - return False - - def get_all_installations(self) -> list[TemplateInstallationRecord]: - """ - Get all installation records. - - Returns: - List of all installation records - - Example: - >>> all_records = tracker.get_all_installations() - """ - return self.load_installation_records() - - def clear_all_installations(self) -> None: - """ - Remove all installation records. - - Example: - >>> tracker.clear_all_installations() - """ - self.save_installation_records([]) diff --git a/devsync/tui/installer.py b/devsync/tui/installer.py deleted file mode 100644 index 1a6dea9..0000000 --- a/devsync/tui/installer.py +++ /dev/null @@ -1,511 +0,0 @@ -"""Textual TUI for installing instructions from library.""" - -from typing import Optional - -from textual import on -from textual.app import App, ComposeResult -from textual.containers import Container, Horizontal, Vertical -from textual.screen import Screen -from textual.widgets import ( - Button, - Checkbox, - DataTable, - Footer, - Header, - Input, - Label, - Select, - Static, -) - -from devsync.ai_tools.detector import get_detector -from devsync.core.models import InstallationScope -from devsync.storage.library import LibraryManager -from devsync.utils.project import find_project_root - - -class InstructionInstallerScreen(Screen): - """Main screen for selecting and installing instructions.""" - - CSS = """ - Screen { - background: $background; - } - - #title-container { - height: 3; - background: $boost; - padding: 1; - border-bottom: solid $primary; - } - - #app-title { - text-align: center; - text-style: bold; - } - - #search-container { - height: 3; - padding: 0 1; - margin-top: 1; - } - - #filter-container { - height: 3; - padding: 0 1; - } - - #instructions-table { - height: 1fr; - } - - #installation-settings { - height: auto; - padding: 1; - background: $panel; - border-top: solid $primary; - } - - #tools-container { - height: auto; - padding: 0 1; - } - - #scope-container { - height: auto; - padding: 0 1; - margin-bottom: 1; - } - - #status-bar { - height: auto; - padding: 1; - background: $surface; - } - - #actions-container { - height: 3; - padding: 0 1; - } - - Button { - margin: 0 1; - } - - Checkbox { - margin: 0 2; - } - - .selected-row { - background: $accent; - } - - .setting-label { - text-style: bold; - color: $primary; - } - - .help-text { - color: $text-muted; - } - """ - - BINDINGS = [ - ("escape", "quit", "Quit"), - ("space", "toggle_selection", "Toggle Selection"), - ("enter", "toggle_selection", "Toggle Selection"), - ("ctrl+a", "select_all", "Select All"), - ("ctrl+d", "deselect_all", "Deselect All"), - ("ctrl+l", "clear_search", "Clear Search"), - ("/", "focus_search", "Search"), - ] - - def __init__( - self, - library: LibraryManager, - tool: Optional[str] = None, - ): - """ - Initialize installer screen. - - Args: - library: Library manager instance - tool: AI tool to install to (ignored - user must select) - """ - super().__init__() - self.library = library - # Always use project scope - self.scope = InstallationScope.PROJECT - self.instructions = library.list_instructions() - self.filtered_instructions = self.instructions.copy() - self.selected_ids: set[str] = set() - - # Get current directory info for display - from pathlib import Path - - self.current_dir = Path.cwd() - self.project_root = find_project_root() - - # Detect available AI tools - detector = get_detector() - self.available_tools = detector.detect_installed_tools() - - # No default tool selection - user must explicitly select - self.selected_tools: set[str] = set() - - def compose(self) -> ComposeResult: - """Create child widgets.""" - yield Header(show_clock=True) - - # Branded title section - with Container(id="title-container"): - yield Static( - "🎯 [bold cyan]DevSync[/bold cyan] [dim]│[/dim] " "Browse & Install Instructions", - id="app-title", - ) - - # Search container - with Container(id="search-container"): - yield Input(placeholder="🔍 Search instructions by name or description...", id="search-input") - - # Filter container - with Horizontal(id="filter-container"): - # Repository filter - repo_options = [("All Repositories", "")] - repos = {inst.repo_namespace: inst.repo_name for inst in self.instructions} - repo_options.extend([(name, namespace) for namespace, name in repos.items()]) - - yield Label("Filter by Repo:") - yield Select( - options=repo_options, - value="", - id="repo-filter", - ) - - # Instructions table - yield DataTable(id="instructions-table") - - # Installation Settings Section - with Container(id="installation-settings"): - yield Label("⚙️ Installation Settings (REQUIRED)", classes="setting-label") - - # Show installation location info - with Vertical(id="scope-container"): - yield Label("Installation location:") - - # Display where files will be installed - if self.project_root: - help_text = f"Files will be installed to: {self.project_root}//rules/" - else: - help_text = f"Files will be installed to: {self.current_dir}//rules/" - yield Static(help_text, id="scope-help", classes="help-text") - - # Target tools selection - with Vertical(id="tools-container"): - yield Label("Install to which AI tools: *") - if self.available_tools: - for tool in self.available_tools: - tool_id = tool.tool_type.value - # Start with nothing checked - user must select - yield Checkbox( - f"{tool.tool_name}", - value=False, - id=f"tool-{tool_id}", - ) - else: - yield Static("⚠️ No AI coding tools detected!", classes="help-text") - - # Status bar - with Container(id="status-bar"): - yield Static("", id="status-text") - - # Action buttons - with Horizontal(id="actions-container"): - yield Button("Cancel", variant="default", id="cancel-btn") - yield Button("Select All", variant="primary", id="select-all-btn") - yield Button("Clear Selection", variant="default", id="deselect-all-btn") - yield Button("📦 Install Selected", variant="success", id="install-btn") - - yield Footer() - - def on_mount(self) -> None: - """Set up the table when mounted.""" - table = self.query_one("#instructions-table", DataTable) - - # Add columns - table.add_column("☑", key="selected", width=3) - table.add_column("Name", key="name", width=25) - table.add_column("Description", key="description", width=40) - table.add_column("Repository", key="repo", width=20) - table.add_column("Author", key="author", width=15) - table.add_column("Ver", key="version", width=8) - table.add_column("Tags", key="tags", width=20) - - # Populate table - self.refresh_table() - self.update_status() - - # Set focus to the table instead of search input - table.focus() - - def refresh_table(self) -> None: - """Refresh the table with filtered instructions.""" - table = self.query_one("#instructions-table", DataTable) - table.clear() - - for inst in self.filtered_instructions: - is_selected = inst.id in self.selected_ids - checkbox = "[✓]" if is_selected else "[ ]" - - # Truncate long text - name = inst.name[:23] + "..." if len(inst.name) > 23 else inst.name - desc = inst.description[:38] + "..." if len(inst.description) > 38 else inst.description - repo = inst.repo_name[:18] + "..." if len(inst.repo_name) > 18 else inst.repo_name - author = inst.author[:13] + "..." if len(inst.author) > 13 else inst.author - tags = ", ".join(inst.tags[:2]) if inst.tags else "-" - if len(inst.tags) > 2: - tags += f" +{len(inst.tags) - 2}" - - table.add_row( - checkbox, - name, - desc, - repo, - author, - inst.version, - tags, - key=inst.id, - ) - - def update_status(self) -> None: - """Update the status bar.""" - status = self.query_one("#status-text", Static) - total = len(self.filtered_instructions) - selected_instructions = len(self.selected_ids) - selected_tools_count = len(self.selected_tools) - - # Tools text - if selected_tools_count == 0: - tools_text = "⚠️ None selected" - elif selected_tools_count == len(self.available_tools): - tools_text = f"All {selected_tools_count} tools" - else: - tools_text = f"{selected_tools_count} tool(s)" - - status.update( - f"Instructions: {selected_instructions} selected | {total} shown | " - f"Target: {tools_text} | Install to: Project" - ) - - def filter_instructions( - self, - search: str = "", - repo_namespace: str = "", - ) -> None: - """ - Filter instructions based on criteria. - - Args: - search: Search query - repo_namespace: Repository namespace filter - """ - self.filtered_instructions = self.instructions.copy() - - # Apply search filter - if search: - query = search.lower() - self.filtered_instructions = [ - inst - for inst in self.filtered_instructions - if query in inst.name.lower() or query in inst.description.lower() - ] - - # Apply repo filter - if repo_namespace: - self.filtered_instructions = [ - inst for inst in self.filtered_instructions if inst.repo_namespace == repo_namespace - ] - - self.refresh_table() - self.update_status() - - @on(Input.Changed, "#search-input") - def on_search_changed(self, event: Input.Changed) -> None: - """Handle search input changes.""" - repo_filter = self.query_one("#repo-filter", Select).value - # Convert to string, handling NoSelection or other non-string values - repo_filter_str = str(repo_filter) if repo_filter is not None else "" - self.filter_instructions(search=event.value, repo_namespace=repo_filter_str) - - @on(Select.Changed, "#repo-filter") - def on_repo_filter_changed(self, event: Select.Changed) -> None: - """Handle repository filter changes.""" - search = self.query_one("#search-input", Input).value - self.filter_instructions(search=search, repo_namespace=str(event.value)) - - @on(Checkbox.Changed) - def on_tool_checkbox_changed(self, event: Checkbox.Changed) -> None: - """Handle tool checkbox changes.""" - # Extract tool name from checkbox ID (format: "tool-cursor") - checkbox_id = event.checkbox.id - if checkbox_id and checkbox_id.startswith("tool-"): - tool_name = checkbox_id[5:] # Remove "tool-" prefix - - if event.value: - self.selected_tools.add(tool_name) - else: - self.selected_tools.discard(tool_name) - - self.update_status() - - @on(DataTable.RowSelected) - def on_row_selected(self, event: DataTable.RowSelected) -> None: - """Toggle selection when row is clicked.""" - if event.row_key: - instruction_id = str(event.row_key.value) - - if instruction_id in self.selected_ids: - self.selected_ids.remove(instruction_id) - else: - self.selected_ids.add(instruction_id) - - self.refresh_table() - self.update_status() - - @on(Button.Pressed, "#select-all-btn") - def action_select_all(self) -> None: - """Select all filtered instructions.""" - self.selected_ids.update(inst.id for inst in self.filtered_instructions) - self.refresh_table() - self.update_status() - - @on(Button.Pressed, "#deselect-all-btn") - def action_deselect_all(self) -> None: - """Deselect all instructions.""" - self.selected_ids.clear() - self.refresh_table() - self.update_status() - - def action_toggle_selection(self) -> None: - """Toggle selection of the currently highlighted row.""" - table = self.query_one("#instructions-table", DataTable) - - # Get the currently highlighted row - if table.cursor_row is not None and table.cursor_row >= 0: - # Get all row keys as a list - row_keys = list(table.rows.keys()) - if table.cursor_row < len(row_keys): - row_key = row_keys[table.cursor_row] - # Access the .value property of the RowKey to get the actual instruction ID - instruction_id = str(row_key.value) - - # Toggle selection - if instruction_id in self.selected_ids: - self.selected_ids.remove(instruction_id) - else: - self.selected_ids.add(instruction_id) - - self.refresh_table() - self.update_status() - - def action_clear_search(self) -> None: - """Clear search input.""" - search_input = self.query_one("#search-input", Input) - search_input.value = "" - search_input.focus() - - def action_focus_search(self) -> None: - """Focus the search input.""" - self.query_one("#search-input", Input).focus() - - @on(Button.Pressed, "#cancel-btn") - def action_quit(self) -> None: - """Cancel and exit.""" - self.dismiss(None) - - @on(Button.Pressed, "#install-btn") - def on_install_pressed(self) -> None: - """Handle install button press.""" - # Validate all required selections - errors = [] - - if not self.selected_ids: - errors.append("Please select at least one instruction") - - if not self.selected_tools: - errors.append("Please select at least one AI tool") - - # Show all errors - if errors: - for error in errors: - self.app.notify(error, severity="error", timeout=4) - return - - # Get selected instructions - selected_instructions = [inst for inst in self.instructions if inst.id in self.selected_ids] - - # Return result with selected tools as a list - self.dismiss( - { - "instructions": selected_instructions, - "tools": list(self.selected_tools), # Return as list - } - ) - - -class InstructionInstallerApp(App): - """Application for installing instructions.""" - - TITLE = "DevSync Installer" - SUB_TITLE = "Browse, Select & Install Instructions" - - def __init__( - self, - library: LibraryManager, - tool: Optional[str] = None, - ): - """ - Initialize installer app. - - Args: - library: Library manager instance - tool: AI tool to install to (None = all) - """ - super().__init__() - self.library = library - self.tool = tool - self.result: Optional[dict] = None - - def on_mount(self) -> None: - """Push the installer screen when app mounts.""" - screen = InstructionInstallerScreen( - library=self.library, - tool=self.tool, - ) - self.push_screen(screen, self.handle_result) - - def handle_result(self, result: Optional[dict]) -> None: - """Handle result from installer screen.""" - self.result = result - self.exit() - - -def show_installer_tui( - library: LibraryManager, - tool: Optional[str] = None, -) -> Optional[dict]: - """ - Show the instruction installer TUI. - - All installations are at project level. - - Args: - library: Library manager instance - tool: AI tool to install to (None = all) - - Returns: - Dictionary with selected instructions and settings, or None if cancelled - """ - app = InstructionInstallerApp(library=library, tool=tool) - app.run() - return app.result diff --git a/tests/e2e/test_basic_workflows.py b/tests/e2e/test_basic_workflows.py deleted file mode 100644 index 3418ed5..0000000 --- a/tests/e2e/test_basic_workflows.py +++ /dev/null @@ -1,307 +0,0 @@ -"""E2E tests for basic package management workflows.""" - -from pathlib import Path - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope, InstallationStatus -from devsync.storage.package_tracker import PackageTracker - - -class TestBasicInstallation: - """Test basic package installation workflows.""" - - def test_install_simple_package_from_directory(self, package_builder, test_project: Path) -> None: - """Test installing a simple package from a local directory.""" - # Create package - pkg = package_builder( - name="simple-pkg", - version="1.0.0", - instructions=[ - {"name": "style-guide", "description": "Style guidelines"}, - ], - ) - - # Install - result = install_package( - package_path=pkg, - project_root=test_project, - target_ide=AIToolType.CLAUDE, - ) - - # Verify - assert result.success is True - assert result.status == InstallationStatus.COMPLETE - assert result.installed_count == 1 - assert result.skipped_count == 0 - - # Verify files exist - assert (test_project / ".claude/rules/style-guide.md").exists() - - # Verify tracking - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("simple-pkg", InstallationScope.PROJECT) - assert pkg_record is not None - assert pkg_record.version == "1.0.0" - - def test_install_complete_package_all_components(self, package_builder, test_project: Path) -> None: - """Test installing a package with all component types.""" - pkg = package_builder( - name="complete-pkg", - version="1.0.0", - instructions=[ - {"name": "style", "description": "Style guide"}, - {"name": "testing", "description": "Test guide"}, - ], - mcp_servers=[ - {"name": "filesystem", "description": "FS access"}, - ], - hooks=[ - {"name": "pre-commit", "description": "Pre-commit hook"}, - ], - commands=[ - {"name": "test", "description": "Run tests"}, - ], - resources=[ - {"name": ".gitignore", "description": "Git ignore file"}, - ], - ) - - result = install_package( - package_path=pkg, - project_root=test_project, - target_ide=AIToolType.CLAUDE, - ) - - # Verify installation - assert result.success is True - assert result.status == InstallationStatus.COMPLETE - assert result.installed_count == 6 - - # Verify all files exist - assert (test_project / ".claude/rules/style.md").exists() - assert (test_project / ".claude/rules/testing.md").exists() - assert (test_project / ".claude/mcp/filesystem.json").exists() - assert (test_project / ".claude/hooks/pre-commit.sh").exists() - assert (test_project / ".claude/commands/test.sh").exists() - assert (test_project / ".gitignore").exists() - - # Verify hook is executable (Unix-only) - import os - - if os.name != "nt": # Skip permission check on Windows - hook_path = test_project / ".claude/hooks/pre-commit.sh" - assert hook_path.stat().st_mode & 0o111 # Has execute permission - - def test_install_to_different_project_locations(self, package_builder, tmp_path: Path) -> None: - """Test installing to different project locations.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "description": "Guide"}], - ) - - # Create multiple projects - project1 = tmp_path / "project1" - project1.mkdir() - project2 = tmp_path / "nested/project2" - project2.mkdir(parents=True) - - # Install to both - result1 = install_package(pkg, project1, AIToolType.CLAUDE) - result2 = install_package(pkg, project2, AIToolType.CLAUDE) - - assert result1.success is True - assert result2.success is True - - # Verify separate installations - assert (project1 / ".claude/rules/guide.md").exists() - assert (project2 / ".claude/rules/guide.md").exists() - assert (project1 / ".devsync/packages.json").exists() - assert (project2 / ".devsync/packages.json").exists() - - -class TestListPackages: - """Test listing installed packages.""" - - def test_list_empty_project(self, test_project: Path) -> None: - """Test listing packages when none are installed.""" - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 0 - - def test_list_single_package(self, package_builder, test_project: Path) -> None: - """Test listing a single installed package.""" - pkg = package_builder( - name="my-package", - version="1.0.0", - instructions=[{"name": "guide", "description": "Guide"}], - ) - - install_package(pkg, test_project, AIToolType.CLAUDE) - - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 1 - assert packages[0].package_name == "my-package" - assert packages[0].version == "1.0.0" - assert packages[0].status == InstallationStatus.COMPLETE - - def test_list_multiple_packages(self, package_builder, test_project: Path) -> None: - """Test listing multiple installed packages.""" - pkg1 = package_builder( - name="package-1", - version="1.0.0", - instructions=[{"name": "guide1", "description": "Guide 1"}], - ) - pkg2 = package_builder( - name="package-2", - version="2.0.0", - instructions=[{"name": "guide2", "description": "Guide 2"}], - ) - - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 2 - package_names = {p.package_name for p in packages} - assert package_names == {"package-1", "package-2"} - - -class TestUninstallPackages: - """Test uninstalling packages.""" - - def test_uninstall_removes_files(self, package_builder, test_project: Path) -> None: - """Test that uninstalling removes all package files.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "description": "Guide"}], - hooks=[{"name": "pre-commit", "description": "Hook"}], - ) - - # Install - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Verify files exist - guide_path = test_project / ".claude/rules/guide.md" - hook_path = test_project / ".claude/hooks/pre-commit.sh" - assert guide_path.exists() - assert hook_path.exists() - - # Uninstall - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record is not None - - # Remove files - for component in pkg_record.components: - file_path = test_project / component.installed_path - if file_path.exists(): - file_path.unlink() - - # Remove from tracker - tracker.remove_package("test-pkg", InstallationScope.PROJECT) - - # Verify - assert not guide_path.exists() - assert not hook_path.exists() - assert tracker.get_package("test-pkg", InstallationScope.PROJECT) is None - - def test_uninstall_does_not_affect_other_packages(self, package_builder, test_project: Path) -> None: - """Test that uninstalling one package doesn't affect others.""" - pkg1 = package_builder( - name="package-1", - version="1.0.0", - instructions=[{"name": "guide1", "description": "Guide 1"}], - ) - pkg2 = package_builder( - name="package-2", - version="1.0.0", - instructions=[{"name": "guide2", "description": "Guide 2"}], - ) - - # Install both - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - - # Uninstall package-1 - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg1_record = tracker.get_package("package-1", InstallationScope.PROJECT) - - for component in pkg1_record.components: - file_path = test_project / component.installed_path - if file_path.exists(): - file_path.unlink() - - tracker.remove_package("package-1", InstallationScope.PROJECT) - - # Verify package-1 is gone but package-2 remains - assert not (test_project / ".claude/rules/guide1.md").exists() - assert (test_project / ".claude/rules/guide2.md").exists() - assert tracker.get_package("package-1", InstallationScope.PROJECT) is None - assert tracker.get_package("package-2", InstallationScope.PROJECT) is not None - - def test_uninstall_nonexistent_package(self, test_project: Path) -> None: - """Test uninstalling a package that doesn't exist.""" - tracker = PackageTracker(test_project / ".devsync/packages.json") - result = tracker.remove_package("nonexistent", InstallationScope.PROJECT) - - assert result is False - - -class TestReinstallation: - """Test reinstalling packages.""" - - def test_reinstall_without_force_skips(self, package_builder, test_project: Path) -> None: - """Test that reinstalling without force detects existing installation.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "description": "Guide"}], - ) - - # Install once - result1 = install_package(pkg, test_project, AIToolType.CLAUDE) - assert result1.success is True - - # Install again without force - result2 = install_package(pkg, test_project, AIToolType.CLAUDE, force=False) - - # Should detect as reinstall - assert result2.is_reinstall is True - - def test_reinstall_with_force_overwrites(self, package_builder, test_project: Path) -> None: - """Test that force reinstall overwrites existing installation.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "description": "Guide"}], - ) - - # Install once - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Modify installed file - guide_path = test_project / ".claude/rules/guide.md" - guide_path.write_text("# Modified content") - - # Force reinstall - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - - # Verify content restored - new_content = guide_path.read_text() - assert new_content != "# Modified content" - assert "guide" in new_content.lower() diff --git a/tests/e2e/test_comprehensive.py b/tests/e2e/test_comprehensive.py deleted file mode 100644 index 378d243..0000000 --- a/tests/e2e/test_comprehensive.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Comprehensive E2E tests covering IDE compatibility, edge cases, errors, and workflows.""" - -import os -import subprocess -from pathlib import Path - -import pytest - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope -from devsync.storage.package_tracker import PackageTracker - - -class TestIDECompatibility: - """Test package installation across different IDEs.""" - - def test_claude_code_installs_all_components(self, package_builder, test_project: Path) -> None: - """Test that Claude Code installs all component types.""" - pkg = package_builder( - name="complete-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - mcp_servers=[{"name": "filesystem", "description": "FS"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'hook'\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'test'\n"}], - resources=[{"name": ".gitignore", "content": "*.pyc"}], - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert result.installed_count == 5 # All components - - # Verify files - assert (test_project / ".claude/rules/guide.md").exists() - assert (test_project / ".claude/mcp/filesystem.json").exists() - assert (test_project / ".claude/hooks/pre-commit.sh").exists() - assert (test_project / ".claude/commands/test.sh").exists() - assert (test_project / ".gitignore").exists() - - def test_cursor_filters_unsupported_components(self, package_builder, test_project: Path) -> None: - """Test that Cursor installs instructions, MCP servers, and resources.""" - pkg = package_builder( - name="complete-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - mcp_servers=[{"name": "filesystem", "description": "FS"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\n"}], - resources=[{"name": ".editorconfig", "content": "root = true"}], - ) - - result = install_package(pkg, test_project, AIToolType.CURSOR) - - assert result.success is True - # Instructions, MCP servers, and resources installed - assert result.installed_count == 3 - # Hooks and commands filtered - assert result.skipped_count == 2 - - # Verify supported components installed - assert (test_project / ".cursor/rules/guide.mdc").exists() # Note: .mdc extension - assert (test_project / ".editorconfig").exists() - - # Unsupported not installed (hooks and commands) - assert not (test_project / ".cursor/hooks").exists() - assert not (test_project / ".cursor/commands").exists() - - def test_cursor_uses_mdc_extension(self, package_builder, test_project: Path) -> None: - """Test that Cursor installs instructions with .mdc extension.""" - pkg = package_builder( - name="cursor-pkg", - version="1.0.0", - instructions=[ - {"name": "style", "content": "# Style"}, - {"name": "testing", "content": "# Testing"}, - ], - ) - - result = install_package(pkg, test_project, AIToolType.CURSOR) - - assert result.success is True - - # Verify .mdc extension - assert (test_project / ".cursor/rules/style.mdc").exists() - assert (test_project / ".cursor/rules/testing.mdc").exists() - - # Not .md - assert not (test_project / ".cursor/rules/style.md").exists() - - def test_windsurf_filters_correctly(self, package_builder, test_project: Path) -> None: - """Test that Windsurf filters components correctly.""" - pkg = package_builder( - name="windsurf-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - mcp_servers=[{"name": "fs", "description": "FS"}], - hooks=[{"name": "hook", "content": "#!/bin/bash\n"}], - commands=[{"name": "cmd", "content": "#!/bin/bash\n"}], - resources=[{"name": ".env.example", "content": "API_KEY="}], - ) - - result = install_package(pkg, test_project, AIToolType.WINSURF) - - assert result.success is True - # Instructions, MCP servers, and resources (hooks/commands not supported) - assert result.installed_count == 3 - - # Verify paths - assert (test_project / ".windsurf/rules/guide.md").exists() - assert (test_project / ".env.example").exists() - - def test_github_copilot_instructions_only(self, package_builder, test_project: Path) -> None: - """Test that GitHub Copilot only supports instructions.""" - pkg = package_builder( - name="copilot-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - resources=[{"name": ".editorconfig", "content": "root"}], - hooks=[{"name": "hook", "content": "#!/bin/bash\n"}], - ) - - result = install_package(pkg, test_project, AIToolType.COPILOT) - - assert result.success is True - # Only instructions - assert result.installed_count == 1 - assert result.skipped_count == 2 - - # Verify path (.github/instructions/) - assert (test_project / ".github/instructions/guide.md").exists() - - # Others not installed - assert not (test_project / ".editorconfig").exists() - - def test_same_package_different_ides(self, package_builder, tmp_path: Path) -> None: - """Test installing the same package to projects using different IDEs.""" - pkg = package_builder( - name="universal-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - mcp_servers=[{"name": "fs", "description": "FS"}], - ) - - # Create four projects for different IDEs - claude_project = tmp_path / "claude" - cursor_project = tmp_path / "cursor" - windsurf_project = tmp_path / "windsurf" - copilot_project = tmp_path / "copilot" - - for proj in [claude_project, cursor_project, windsurf_project, copilot_project]: - proj.mkdir() - - # Install to each - claude_result = install_package(pkg, claude_project, AIToolType.CLAUDE) - cursor_result = install_package(pkg, cursor_project, AIToolType.CURSOR) - windsurf_result = install_package(pkg, windsurf_project, AIToolType.WINSURF) - copilot_result = install_package(pkg, copilot_project, AIToolType.COPILOT) - - # All IDEs now support MCP servers - assert claude_result.installed_count == 2 # instruction + mcp - assert cursor_result.installed_count == 2 # instruction + mcp - assert windsurf_result.installed_count == 2 # instruction + mcp - assert copilot_result.installed_count == 2 # instruction + mcp - - # Verify paths - assert (claude_project / ".claude/rules/guide.md").exists() - assert (cursor_project / ".cursor/rules/guide.mdc").exists() - assert (windsurf_project / ".windsurf/rules/guide.md").exists() - assert (copilot_project / ".github/instructions/guide.md").exists() - - -class TestEdgeCases: - """Test edge cases and unusual scenarios.""" - - def test_package_with_very_long_names(self, package_builder, test_project: Path) -> None: - """Test package and component with very long names.""" - long_instruction_name = "a" * 50 # 50 character name - - pkg = package_builder( - name="long-package", - version="1.0.0", - instructions=[{"name": long_instruction_name, "content": "# Long"}], - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / f".claude/rules/{long_instruction_name}.md").exists() - - def test_package_with_many_components(self, package_builder, test_project: Path) -> None: - """Test package with large number of components.""" - # Create 50 instructions - instructions = [{"name": f"guide-{i:02d}", "content": f"# Guide {i}"} for i in range(50)] - - pkg = package_builder( - name="large-pkg", - version="1.0.0", - instructions=instructions, - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert result.installed_count == 50 - - # Verify all installed - for i in range(50): - assert (test_project / f".claude/rules/guide-{i:02d}.md").exists() - - @pytest.mark.skipif(os.name == "nt", reason="Unicode encoding issues on Windows") - def test_package_with_special_characters_in_content(self, package_builder, test_project: Path) -> None: - """Test package with special characters and unicode in content.""" - content = """# Guide with Special Characters - -## Symbols -- © Copyright -- ™ Trademark -- ® Registered -- € Euro -- ¥ Yen - -## Code blocks -```python -def hello(): - print("Hello, 世界!") # Unicode -``` - -## Emojis -🚀 🎯 ✨ 💡 📦 -""" - - pkg = package_builder( - name="special-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": content}], - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - - installed_content = (test_project / ".claude/rules/guide.md").read_text() - assert "世界" in installed_content - assert "🚀" in installed_content - assert "©" in installed_content - - def test_empty_package_no_components(self, test_project: Path, tmp_path: Path) -> None: - """Test package with valid manifest but no components.""" - # Create minimal package - pkg_path = tmp_path / "empty-pkg" - pkg_path.mkdir() - - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: empty-pkg -version: 1.0.0 -description: Empty package -author: Test -namespace: test/empty -license: MIT - -components: -""" - ) - - # Should succeed but install nothing - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - assert result.success is True - assert result.installed_count == 0 - - def test_package_with_nested_directory_structures( - self, package_builder, test_project: Path, tmp_path: Path - ) -> None: - """Test package with deeply nested directory structures.""" - pkg_path = tmp_path / "nested-pkg" - pkg_path.mkdir() - - # Create nested structure - (pkg_path / "instructions/deep/nested/path").mkdir(parents=True) - (pkg_path / "instructions/deep/nested/path/guide.md").write_text("# Deep") - - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: nested-pkg -version: 1.0.0 -description: Nested package -author: Test -namespace: test/nested -license: MIT - -components: - instructions: - - name: deep-guide - description: Deeply nested guide - file: instructions/deep/nested/path/guide.md - tags: [nested] -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / ".claude/rules/deep-guide.md").exists() - - def test_package_with_binary_resource_content(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test package with binary file as resource.""" - pkg_path = tmp_path / "binary-pkg" - pkg_path.mkdir() - (pkg_path / "resources").mkdir() - - # Create a small binary file (PNG-like header) - binary_data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" - (pkg_path / "resources/icon.png").write_bytes(binary_data) - - # Calculate checksum - import hashlib - - checksum = hashlib.sha256(binary_data).hexdigest() - - (pkg_path / "ai-config-kit-package.yaml").write_text( - f"""name: binary-pkg -version: 1.0.0 -description: Package with binary -author: Test -namespace: test/binary -license: MIT - -components: - resources: - - name: icon - description: Icon file - file: resources/icon.png - install_path: assets/icon.png - checksum: sha256:{checksum} - size: {len(binary_data)} - tags: [binary] -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - - installed_file = test_project / "assets/icon.png" - assert installed_file.exists() - assert installed_file.read_bytes() == binary_data - - -class TestErrorHandling: - """Test error handling and validation.""" - - def test_missing_manifest_file(self, test_project: Path, tmp_path: Path) -> None: - """Test installing from directory without manifest.""" - empty_dir = tmp_path / "no-manifest" - empty_dir.mkdir() - - result = install_package(empty_dir, test_project, AIToolType.CLAUDE) - - assert result.success is False - assert "manifest" in result.error_message.lower() - - def test_invalid_manifest_yaml(self, test_project: Path, tmp_path: Path) -> None: - """Test manifest with invalid YAML syntax.""" - pkg_path = tmp_path / "invalid-yaml" - pkg_path.mkdir() - - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: test -version: 1.0.0 -description: Test - invalid yaml syntax here -author: Test -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is False - assert result.error_message is not None - - def test_missing_required_manifest_fields(self, test_project: Path, tmp_path: Path) -> None: - """Test manifest missing required fields.""" - pkg_path = tmp_path / "incomplete" - pkg_path.mkdir() - - # Missing 'author' field - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: test -version: 1.0.0 -description: Test -namespace: test/pkg -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is False - assert "author" in result.error_message.lower() - - def test_component_file_does_not_exist(self, test_project: Path, tmp_path: Path) -> None: - """Test component referencing non-existent file.""" - pkg_path = tmp_path / "missing-file" - pkg_path.mkdir() - - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: missing-file-pkg -version: 1.0.0 -description: Test -author: Test -namespace: test/pkg -license: MIT - -components: - instructions: - - name: nonexistent - description: This file doesn't exist - file: instructions/nonexistent.md - tags: [test] -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is False - assert "not found" in result.error_message.lower() or "exist" in result.error_message.lower() - - def test_invalid_version_format(self, test_project: Path, tmp_path: Path) -> None: - """Test manifest with invalid version format.""" - pkg_path = tmp_path / "bad-version" - pkg_path.mkdir() - (pkg_path / "instructions").mkdir() - (pkg_path / "instructions/guide.md").write_text("# Guide") - - (pkg_path / "ai-config-kit-package.yaml").write_text( - """name: bad-version -version: not-a-version -description: Test -author: Test -namespace: test/pkg -license: MIT - -components: - instructions: - - name: guide - description: Guide - file: instructions/guide.md - tags: [test] -""" - ) - - result = install_package(pkg_path, test_project, AIToolType.CLAUDE) - - assert result.success is False - assert result.error_message is not None - - def test_install_to_nonexistent_project_directory(self, package_builder, tmp_path: Path) -> None: - """Test installing to a project directory that doesn't exist.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - ) - - nonexistent = tmp_path / "does-not-exist" - - # Should either create directory or raise appropriate error - # (depending on implementation) - try: - _result = install_package(pkg, nonexistent, AIToolType.CLAUDE) - # If it succeeds, directory should be created - assert nonexistent.exists() - except Exception as e: - # Or it should raise a clear error - assert "not found" in str(e).lower() or "exist" in str(e).lower() - - -class TestRealWorldWorkflows: - """Test realistic end-to-end workflows.""" - - def test_new_developer_onboarding_workflow(self, package_builder, tmp_path: Path) -> None: - """Simulate complete new developer onboarding.""" - # New dev clones project - project = tmp_path / "backend-api" - project.mkdir() - - # Company security package (would be git cloned) - security_pkg = package_builder( - name="company-security", - version="1.0.0", - instructions=[ - {"name": "security-policy", "content": "# Security policies"}, - {"name": "code-review", "content": "# Code review checklist"}, - ], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'Security checks'\n"}], - ) - - # Backend team standards - backend_pkg = package_builder( - name="backend-standards", - version="2.1.0", - instructions=[ - {"name": "api-design", "content": "# API design"}, - {"name": "database-patterns", "content": "# DB patterns"}, - ], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'Run tests'\n"}], - ) - - # Python-specific package - python_pkg = package_builder( - name="python-style", - version="1.5.0", - instructions=[ - {"name": "pep8", "content": "# PEP 8 style"}, - ], - ) - - # Install all packages - install_package(security_pkg, project, AIToolType.CLAUDE) - install_package(backend_pkg, project, AIToolType.CLAUDE) - install_package(python_pkg, project, AIToolType.CLAUDE) - - # Verify complete setup - tracker = PackageTracker(project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 3 - assert {p.package_name for p in packages} == {"company-security", "backend-standards", "python-style"} - - # All files installed - assert (project / ".claude/rules/security-policy.md").exists() - assert (project / ".claude/rules/api-design.md").exists() - assert (project / ".claude/rules/pep8.md").exists() - assert (project / ".claude/hooks/pre-commit.sh").exists() - assert (project / ".claude/commands/test.sh").exists() - - def test_team_sync_workflow(self, package_builder, tmp_path: Path) -> None: - """Test team keeping packages in sync.""" - # Create team package repo - team_pkg_v1 = package_builder( - name="team-standards", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0"}], - as_git=True, - ) - - # Three team members install - dev1 = tmp_path / "dev1" - dev2 = tmp_path / "dev2" - dev3 = tmp_path / "dev3" - - for dev in [dev1, dev2, dev3]: - dev.mkdir() - install_package(team_pkg_v1, dev, AIToolType.CLAUDE) - - # Package is updated in repo - (team_pkg_v1 / "instructions/guide.md").write_text("# v1.1\n\nUpdated") - manifest = (team_pkg_v1 / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0") - (team_pkg_v1 / "ai-config-kit-package.yaml").write_text(manifest) - - subprocess.run(["git", "add", "."], cwd=team_pkg_v1, check=True) - subprocess.run( - ["git", "commit", "-m", "Update to 1.1.0"], - cwd=team_pkg_v1, - check=True, - ) - - # All team members update - for dev in [dev1, dev2, dev3]: - install_package( - team_pkg_v1, - dev, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # Verify all on same version - for dev in [dev1, dev2, dev3]: - tracker = PackageTracker(dev / ".devsync/packages.json") - record = tracker.get_package("team-standards", InstallationScope.PROJECT) - assert record.version == "1.1.0" - - def test_multi_project_selective_packages(self, package_builder, tmp_path: Path) -> None: - """Test installing different packages to different projects.""" - # Create packages - frontend_pkg = package_builder( - name="frontend-pkg", - version="1.0.0", - instructions=[{"name": "react", "content": "# React"}], - ) - backend_pkg = package_builder( - name="backend-pkg", - version="1.0.0", - instructions=[{"name": "api", "content": "# API"}], - ) - shared_pkg = package_builder( - name="shared-pkg", - version="1.0.0", - instructions=[{"name": "git", "content": "# Git"}], - ) - - # Create projects - frontend = tmp_path / "frontend" - backend = tmp_path / "backend" - frontend.mkdir() - backend.mkdir() - - # Frontend gets frontend + shared - install_package(frontend_pkg, frontend, AIToolType.CLAUDE) - install_package(shared_pkg, frontend, AIToolType.CLAUDE) - - # Backend gets backend + shared - install_package(backend_pkg, backend, AIToolType.CLAUDE) - install_package(shared_pkg, backend, AIToolType.CLAUDE) - - # Verify frontend - fe_tracker = PackageTracker(frontend / ".devsync/packages.json") - fe_packages = {p.package_name for p in fe_tracker.get_installed_packages()} - assert fe_packages == {"frontend-pkg", "shared-pkg"} - - # Verify backend - be_tracker = PackageTracker(backend / ".devsync/packages.json") - be_packages = {p.package_name for p in be_tracker.get_installed_packages()} - assert be_packages == {"backend-pkg", "shared-pkg"} - - def test_migration_from_manual_to_packages(self, package_builder, test_project: Path) -> None: - """Test migrating from manual files to package management.""" - # User has manually created files - rules_dir = test_project / ".claude/rules" - rules_dir.mkdir(parents=True) - (rules_dir / "my-custom-style.md").write_text("# My Custom Style\n") - (rules_dir / "my-testing.md").write_text("# My Testing Guide\n") - - # Now install a package with SKIP to preserve custom files - pkg = package_builder( - name="official-standards", - version="1.0.0", - instructions=[ - {"name": "company-style", "content": "# Company Style"}, - {"name": "company-testing", "content": "# Company Testing"}, - ], - ) - - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - - # Custom files still exist - assert (rules_dir / "my-custom-style.md").exists() - assert (rules_dir / "my-testing.md").exists() - - # Package files added - assert (rules_dir / "company-style.md").exists() - assert (rules_dir / "company-testing.md").exists() - - # Package is tracked - tracker = PackageTracker(test_project / ".devsync/packages.json") - assert tracker.is_package_installed("official-standards", InstallationScope.PROJECT) - - def test_cleanup_old_versions_workflow(self, package_builder, test_project: Path) -> None: - """Test cleaning up after multiple version installs with RENAME.""" - pkg_v1 = package_builder( - name="evolving-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0"}], - ) - - # Install v1 - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # Install v2 and v3 with RENAME - for version in ["2.0.0", "3.0.0"]: - pkg = package_builder( - name="evolving-pkg", - version=version, - instructions=[{"name": "guide", "content": f"# v{version}"}], - ) - install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Should have guide.md, guide-1.md, guide-2.md - rules_dir = test_project / ".claude/rules" - assert (rules_dir / "guide.md").exists() - assert (rules_dir / "guide-1.md").exists() - assert (rules_dir / "guide-2.md").exists() - - # Clean up old versions - (rules_dir / "guide-1.md").unlink() - (rules_dir / "guide-2.md").unlink() - - # Only latest remains - assert (rules_dir / "guide.md").exists() - assert not (rules_dir / "guide-1.md").exists() diff --git a/tests/e2e/test_conflict_resolution.py b/tests/e2e/test_conflict_resolution.py deleted file mode 100644 index dae3826..0000000 --- a/tests/e2e/test_conflict_resolution.py +++ /dev/null @@ -1,534 +0,0 @@ -"""E2E tests for conflict resolution strategies.""" - -from pathlib import Path - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope -from devsync.storage.package_tracker import PackageTracker - - -class TestSkipStrategy: - """Test SKIP conflict resolution strategy.""" - - def test_skip_preserves_user_modifications(self, package_builder, test_project: Path) -> None: - """Test that SKIP strategy preserves user modifications.""" - # Install package - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide", "content": "# Original\n\nPackage content"}, - ], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # User modifies file - guide_path = test_project / ".claude/rules/guide.md" - user_content = "# Custom\n\nUser modifications" - guide_path.write_text(user_content) - - # Reinstall with SKIP - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - assert result.skipped_count > 0 - - # Verify user modifications preserved - assert guide_path.read_text() == user_content - - def test_skip_only_affects_existing_files(self, package_builder, test_project: Path) -> None: - """Test that SKIP only affects files that already exist.""" - # Install v1 with one instruction - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide1", "content": "# Guide 1"}, - ], - ) - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # Modify existing file - guide1_path = test_project / ".claude/rules/guide1.md" - guide1_path.write_text("# Modified") - - # Install v2 with two instructions - pkg_v2 = package_builder( - name="test-pkg", - version="1.1.0", - instructions=[ - {"name": "guide1", "content": "# Guide 1 Updated"}, - {"name": "guide2", "content": "# Guide 2 New"}, - ], - ) - - result = install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - - # Existing file skipped - assert guide1_path.read_text() == "# Modified" - - # New file installed - guide2_path = test_project / ".claude/rules/guide2.md" - assert guide2_path.exists() - assert "Guide 2 New" in guide2_path.read_text() - - def test_skip_with_multiple_file_types(self, package_builder, test_project: Path) -> None: - """Test SKIP strategy with different component types.""" - # Install package with multiple components - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'original'\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'test'\n"}], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Modify instruction and hook, leave command unchanged - (test_project / ".claude/rules/guide.md").write_text("# Modified Guide") - (test_project / ".claude/hooks/pre-commit.sh").write_text("#!/bin/bash\necho 'modified'\n") - - # Reinstall with SKIP - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - assert result.skipped_count >= 2 # Instruction and hook skipped - - # Verify modifications preserved - assert "Modified Guide" in (test_project / ".claude/rules/guide.md").read_text() - assert "modified" in (test_project / ".claude/hooks/pre-commit.sh").read_text() - - -class TestOverwriteStrategy: - """Test OVERWRITE conflict resolution strategy.""" - - def test_overwrite_replaces_user_modifications(self, package_builder, test_project: Path) -> None: - """Test that OVERWRITE replaces all user modifications.""" - # Install package - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide", "content": "# Original Package Content\n"}, - ], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # User modifies file - guide_path = test_project / ".claude/rules/guide.md" - guide_path.write_text("# User Modifications\n\nCustom content") - - # Reinstall with OVERWRITE - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - assert result.skipped_count == 0 - - # Verify package content restored - content = guide_path.read_text() - assert "Original Package Content" in content - assert "User Modifications" not in content - - def test_overwrite_all_component_types(self, package_builder, test_project: Path) -> None: - """Test OVERWRITE with all component types.""" - # Install package - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Original"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'original'\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'test original'\n"}], - resources=[{"name": ".gitignore", "content": "# Original gitignore"}], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Modify all files - (test_project / ".claude/rules/guide.md").write_text("# Modified") - (test_project / ".claude/hooks/pre-commit.sh").write_text("#!/bin/bash\necho 'modified'\n") - (test_project / ".claude/commands/test.sh").write_text("#!/bin/bash\necho 'modified'\n") - (test_project / ".gitignore").write_text("# Modified gitignore") - - # Reinstall with OVERWRITE - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - - # Verify all restored - assert "# Original" in (test_project / ".claude/rules/guide.md").read_text() - assert "echo 'original'" in (test_project / ".claude/hooks/pre-commit.sh").read_text() - assert "echo 'test original'" in (test_project / ".claude/commands/test.sh").read_text() - assert "# Original gitignore" in (test_project / ".gitignore").read_text() - - def test_overwrite_during_version_update(self, package_builder, test_project: Path) -> None: - """Test OVERWRITE when updating package versions.""" - # Install v1.0.0 - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0 Content"}], - ) - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # User makes modifications - guide_path = test_project / ".claude/rules/guide.md" - guide_path.write_text("# User Content") - - # Install v2.0.0 with OVERWRITE - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[{"name": "guide", "content": "# v2.0.0 Content\n\nNew features"}], - ) - - result = install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - - # Verify v2 content installed - content = guide_path.read_text() - assert "v2.0.0 Content" in content - assert "New features" in content - assert "User Content" not in content - - # Verify version updated - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "2.0.0" - - -class TestRenameStrategy: - """Test RENAME conflict resolution strategy.""" - - def test_rename_creates_numbered_copy(self, package_builder, test_project: Path) -> None: - """Test that RENAME creates numbered copies.""" - # Install package - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Original"}], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Modify file - original_path = test_project / ".claude/rules/guide.md" - original_path.write_text("# User Modified") - - # Reinstall with RENAME - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - assert result.success is True - - # Original file preserved - assert original_path.exists() - assert "User Modified" in original_path.read_text() - - # New numbered file created - renamed_path = test_project / ".claude/rules/guide-1.md" - assert renamed_path.exists() - assert "# Original" in renamed_path.read_text() - - def test_rename_increments_number(self, package_builder, test_project: Path) -> None: - """Test that RENAME increments number for multiple installs.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Version"}], - ) - - # Install original - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Install with RENAME (creates guide-1.md) - pkg_v2 = package_builder( - name="test-pkg", - version="1.1.0", - instructions=[{"name": "guide", "content": "# Version 1.1"}], - ) - install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Install with RENAME again (creates guide-2.md) - pkg_v3 = package_builder( - name="test-pkg", - version="1.2.0", - instructions=[{"name": "guide", "content": "# Version 1.2"}], - ) - install_package( - pkg_v3, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Verify all three versions exist - rules_dir = test_project / ".claude/rules" - assert (rules_dir / "guide.md").exists() - assert (rules_dir / "guide-1.md").exists() - assert (rules_dir / "guide-2.md").exists() - - def test_rename_with_all_component_types(self, package_builder, test_project: Path) -> None: - """Test RENAME works with all component types.""" - # Install package - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'v1'\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'test v1'\n"}], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Reinstall with RENAME - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[{"name": "guide", "content": "# v2"}], - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'v2'\n"}], - commands=[{"name": "test", "content": "#!/bin/bash\necho 'test v2'\n"}], - ) - - result = install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - assert result.success is True - - # Verify original files preserved - assert (test_project / ".claude/rules/guide.md").exists() - assert (test_project / ".claude/hooks/pre-commit.sh").exists() - assert (test_project / ".claude/commands/test.sh").exists() - - # Verify numbered copies created - assert (test_project / ".claude/rules/guide-1.md").exists() - assert (test_project / ".claude/hooks/pre-commit-1.sh").exists() - assert (test_project / ".claude/commands/test-1.sh").exists() - - def test_rename_preserves_permissions(self, package_builder, test_project: Path) -> None: - """Test that RENAME preserves executable permissions for scripts.""" - # Install package with hook - pkg = package_builder( - name="test-pkg", - version="1.0.0", - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'v1'\n"}], - ) - install_package(pkg, test_project, AIToolType.CLAUDE) - - # Reinstall with RENAME - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - hooks=[{"name": "pre-commit", "content": "#!/bin/bash\necho 'v2'\n"}], - ) - - install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Verify both are executable (Unix-only) - import os - - if os.name != "nt": # Skip permission check on Windows - original = test_project / ".claude/hooks/pre-commit.sh" - renamed = test_project / ".claude/hooks/pre-commit-1.sh" - - assert original.stat().st_mode & 0o111 # Executable - assert renamed.stat().st_mode & 0o111 # Executable - - -class TestConflictResolutionCombinations: - """Test different conflict scenarios and strategy combinations.""" - - def test_partial_conflicts_with_skip(self, package_builder, test_project: Path) -> None: - """Test package with some conflicting and some new files with SKIP.""" - # Install v1 with 2 instructions - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide1", "content": "# Guide 1"}, - {"name": "guide2", "content": "# Guide 2"}, - ], - ) - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # Modify one file - (test_project / ".claude/rules/guide1.md").write_text("# Modified") - - # Install v2 with 3 instructions (guide1 updated, guide2 same, guide3 new) - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[ - {"name": "guide1", "content": "# Guide 1 Updated"}, - {"name": "guide2", "content": "# Guide 2"}, - {"name": "guide3", "content": "# Guide 3 New"}, - ], - ) - - result = install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - - # guide1: skipped (modified) - assert "# Modified" in (test_project / ".claude/rules/guide1.md").read_text() - - # guide2: could be skipped or overwritten (depends on checksum) - assert (test_project / ".claude/rules/guide2.md").exists() - - # guide3: installed (new) - assert (test_project / ".claude/rules/guide3.md").exists() - assert "Guide 3 New" in (test_project / ".claude/rules/guide3.md").read_text() - - def test_no_conflicts_all_strategies_identical(self, package_builder, test_project: Path) -> None: - """Test that all strategies work identically when there are no conflicts.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Guide"}], - ) - - # Test with each strategy - for strategy in [ConflictResolution.SKIP, ConflictResolution.OVERWRITE, ConflictResolution.RENAME]: - # Clean project - import shutil - - claude_dir = test_project / ".claude" - aikit_dir = test_project / ".devsync" - if claude_dir.exists(): - shutil.rmtree(claude_dir) - if aikit_dir.exists(): - shutil.rmtree(aikit_dir) - - # Install with strategy - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - conflict_resolution=strategy, - ) - - assert result.success is True - assert (test_project / ".claude/rules/guide.md").exists() - - def test_changing_strategies_between_installs(self, package_builder, test_project: Path) -> None: - """Test using different strategies for different installs.""" - pkg = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Original"}], - ) - - # Install with SKIP - install_package( - pkg, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.SKIP, - ) - - # Modify - (test_project / ".claude/rules/guide.md").write_text("# Modified") - - # Reinstall with RENAME - pkg_v2 = package_builder( - name="test-pkg", - version="1.1.0", - instructions=[{"name": "guide", "content": "# v1.1"}], - ) - - install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Should have both files - assert (test_project / ".claude/rules/guide.md").exists() - assert (test_project / ".claude/rules/guide-1.md").exists() - - # Reinstall with OVERWRITE - pkg_v3 = package_builder( - name="test-pkg", - version="1.2.0", - instructions=[{"name": "guide", "content": "# v1.2"}], - ) - - install_package( - pkg_v3, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # guide.md should be overwritten - assert "# v1.2" in (test_project / ".claude/rules/guide.md").read_text() - - # guide-1.md should still exist - assert (test_project / ".claude/rules/guide-1.md").exists() diff --git a/tests/e2e/test_git_operations.py b/tests/e2e/test_git_operations.py deleted file mode 100644 index 13fb144..0000000 --- a/tests/e2e/test_git_operations.py +++ /dev/null @@ -1,513 +0,0 @@ -"""E2E tests for git repository operations with packages.""" - -import subprocess -from pathlib import Path - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope -from devsync.storage.package_tracker import PackageTracker - - -class TestGitRepositoryInstallation: - """Test installing packages from git repositories.""" - - def test_install_from_local_git_repo(self, package_builder, test_project: Path) -> None: - """Test installing a package from a local git repository.""" - pkg = package_builder( - name="git-pkg", - version="1.0.0", - instructions=[{"name": "guide", "description": "Git-based guide"}], - as_git=True, - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / ".claude/rules/guide.md").exists() - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("git-pkg", InstallationScope.PROJECT) - assert pkg_record is not None - assert pkg_record.version == "1.0.0" - - def test_install_from_cloned_repo(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test installing from a git repository that was cloned.""" - # Create original repo - original = package_builder( - name="original-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Original"}], - as_git=True, - ) - - # Clone it - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(original), str(clone_path)], - check=True, - capture_output=True, - ) - - # Install from clone - result = install_package(clone_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / ".claude/rules/guide.md").exists() - - def test_install_after_git_pull_updates_package(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test that pulling updates from git and reinstalling updates the package.""" - # Create repo with v1.0.0 - repo = package_builder( - name="updated-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Version 1"}], - as_git=True, - ) - - # Clone it - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - - # Install v1.0.0 - install_package(clone_path, test_project, AIToolType.CLAUDE) - - # Update original repo - (repo / "instructions/guide.md").write_text("# Version 2\n\nUpdated content") - manifest = (repo / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0") - (repo / "ai-config-kit-package.yaml").write_text(manifest) - - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Update to 1.1.0"], cwd=repo, check=True) - - # Pull updates - subprocess.run( - ["git", "pull"], - cwd=clone_path, - check=True, - capture_output=True, - ) - - # Reinstall - result = install_package( - clone_path, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - - # Verify updated - content = (test_project / ".claude/rules/guide.md").read_text() - assert "Version 2" in content - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("updated-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.1.0" - - -class TestGitBranches: - """Test installing from different git branches.""" - - def test_install_from_main_branch(self, package_builder, test_project: Path) -> None: - """Test installing from main branch.""" - pkg = package_builder( - name="main-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Main branch"}], - as_git=True, - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - - assert result.success is True - - content = (test_project / ".claude/rules/guide.md").read_text() - assert "Main branch" in content - - def test_install_from_feature_branch(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test installing from a feature branch.""" - # Create repo on main - repo = package_builder( - name="branch-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Main branch"}], - as_git=True, - ) - - # Create feature branch - subprocess.run( - ["git", "checkout", "-b", "feature/new-feature"], - cwd=repo, - check=True, - capture_output=True, - ) - - # Make changes on feature branch - (repo / "instructions/guide.md").write_text("# Feature branch\n\nNew feature") - (repo / "instructions/experimental.md").write_text("# Experimental\n") - - # Update manifest to include new instruction - manifest = (repo / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0-dev") - manifest += """ - name: experimental - description: Experimental feature - file: instructions/experimental.md - tags: [experimental] -""" - (repo / "ai-config-kit-package.yaml").write_text(manifest) - - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run( - ["git", "commit", "-m", "Feature branch changes"], - cwd=repo, - check=True, - ) - - # Clone and checkout feature branch - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", "-b", "feature/new-feature", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - - # Install from feature branch - result = install_package(clone_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / ".claude/rules/guide.md").exists() - assert (test_project / ".claude/rules/experimental.md").exists() - - content = (test_project / ".claude/rules/guide.md").read_text() - assert "Feature branch" in content - - def test_switch_between_branches(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test switching between branches and reinstalling.""" - # Create repo with two branches - repo = package_builder( - name="multi-branch-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Main"}], - as_git=True, - ) - - # Create dev branch with different content - subprocess.run( - ["git", "checkout", "-b", "dev"], - cwd=repo, - check=True, - capture_output=True, - ) - (repo / "instructions/guide.md").write_text("# Dev branch") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Dev changes"], cwd=repo, check=True) - - # Clone - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - - # Install from main - subprocess.run( - ["git", "checkout", "master"], - cwd=clone_path, - check=True, - capture_output=True, - ) - install_package(clone_path, test_project, AIToolType.CLAUDE) - - content_main = (test_project / ".claude/rules/guide.md").read_text() - assert "Main" in content_main - - # Switch to dev and reinstall - subprocess.run( - ["git", "checkout", "dev"], - cwd=clone_path, - check=True, - capture_output=True, - ) - install_package( - clone_path, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - content_dev = (test_project / ".claude/rules/guide.md").read_text() - assert "Dev branch" in content_dev - - -class TestGitTags: - """Test installing from git tags.""" - - def test_install_from_latest_tag(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test installing from the latest git tag.""" - # Create repo with multiple tags - repo = package_builder( - name="tagged-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0"}], - as_git=True, - ) - - # Create v1.1.0 - (repo / "instructions/guide.md").write_text("# v1.1.0") - manifest = (repo / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0") - (repo / "ai-config-kit-package.yaml").write_text(manifest) - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "v1.1.0"], cwd=repo, check=True) - subprocess.run(["git", "tag", "v1.1.0"], cwd=repo, check=True) - - # Install latest (v1.1.0) - result = install_package(repo, test_project, AIToolType.CLAUDE) - - assert result.success is True - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("tagged-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.1.0" - - def test_install_specific_tag_version(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test checking out and installing a specific tag.""" - # Create repo with v1.0.0 and v2.0.0 - repo = package_builder( - name="versioned-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0"}], - as_git=True, - ) - - # Create v2.0.0 - (repo / "instructions/guide.md").write_text("# v2.0.0") - manifest = (repo / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 2.0.0") - (repo / "ai-config-kit-package.yaml").write_text(manifest) - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "v2.0.0"], cwd=repo, check=True) - subprocess.run(["git", "tag", "v2.0.0"], cwd=repo, check=True) - - # Clone and checkout v1.0.0 - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - subprocess.run( - ["git", "checkout", "v1.0.0"], - cwd=clone_path, - check=True, - capture_output=True, - ) - - # Install v1.0.0 - result = install_package(clone_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - - # Verify v1.0.0 installed - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("versioned-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.0" - - content = (test_project / ".claude/rules/guide.md").read_text() - assert "v1.0.0" in content - assert "v2.0.0" not in content - - -class TestGitCommits: - """Test installing from specific commits.""" - - def test_install_from_specific_commit(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test checking out and installing from a specific commit.""" - # Create repo with multiple commits - repo = package_builder( - name="commit-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# First"}], - as_git=True, - ) - - # Get first commit hash - first_commit = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=repo, - check=True, - capture_output=True, - text=True, - ).stdout.strip() - - # Make second commit - (repo / "instructions/guide.md").write_text("# Second") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Second"], cwd=repo, check=True) - - # Make third commit - (repo / "instructions/guide.md").write_text("# Third") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Third"], cwd=repo, check=True) - - # Clone and checkout first commit - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - subprocess.run( - ["git", "checkout", first_commit], - cwd=clone_path, - check=True, - capture_output=True, - ) - - # Install from first commit - result = install_package(clone_path, test_project, AIToolType.CLAUDE) - - assert result.success is True - - # Verify content from first commit - content = (test_project / ".claude/rules/guide.md").read_text() - assert "First" in content - assert "Second" not in content - assert "Third" not in content - - def test_update_to_latest_commit(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test updating from old commit to latest commit.""" - # Create repo - repo = package_builder( - name="evolving-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Old"}], - as_git=True, - ) - - # Clone - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(repo), str(clone_path)], - check=True, - capture_output=True, - ) - - # Install old version - install_package(clone_path, test_project, AIToolType.CLAUDE) - - content_old = (test_project / ".claude/rules/guide.md").read_text() - assert "Old" in content_old - - # Update original repo - (repo / "instructions/guide.md").write_text("# New\n\nLatest updates") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run(["git", "commit", "-m", "Latest"], cwd=repo, check=True) - - # Pull and reinstall - subprocess.run(["git", "pull"], cwd=clone_path, check=True, capture_output=True) - install_package( - clone_path, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - content_new = (test_project / ".claude/rules/guide.md").read_text() - assert "New" in content_new - assert "Latest updates" in content_new - - -class TestGitHistory: - """Test scenarios involving git history.""" - - def test_install_then_package_repo_history_changes(self, package_builder, test_project: Path) -> None: - """Test that package can be updated even after git history is rewritten.""" - # Create repo - repo = package_builder( - name="history-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Original"}], - as_git=True, - ) - - # Install - install_package(repo, test_project, AIToolType.CLAUDE) - - # Amend last commit (rewrite history) - (repo / "instructions/guide.md").write_text("# Amended") - subprocess.run(["git", "add", "."], cwd=repo, check=True) - subprocess.run( - ["git", "commit", "--amend", "--no-edit"], - cwd=repo, - check=True, - capture_output=True, - ) - - # Force reinstall - result = install_package( - repo, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - assert result.success is True - - content = (test_project / ".claude/rules/guide.md").read_text() - assert "Amended" in content - - def test_install_from_repo_with_no_commits(self, test_project: Path, tmp_path: Path) -> None: - """Test installing from a freshly initialized git repo with no commits.""" - # Create empty git repo - empty_repo = tmp_path / "empty-repo" - empty_repo.mkdir() - - subprocess.run(["git", "init"], cwd=empty_repo, check=True) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=empty_repo, - check=True, - ) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=empty_repo, - check=True, - ) - - # Add package files but don't commit - (empty_repo / "ai-config-kit-package.yaml").write_text( - """name: no-commit-pkg -version: 1.0.0 -description: Package with no commits -author: Test -namespace: test/pkg -license: MIT - -components: - instructions: - - name: guide - description: Guide - file: instructions/guide.md - tags: [test] -""" - ) - (empty_repo / "instructions").mkdir() - (empty_repo / "instructions/guide.md").write_text("# Guide") - - # Should still be able to install from working directory - result = install_package(empty_repo, test_project, AIToolType.CLAUDE) - - assert result.success is True - assert (test_project / ".claude/rules/guide.md").exists() diff --git a/tests/e2e/test_multi_package.py b/tests/e2e/test_multi_package.py deleted file mode 100644 index 64285ad..0000000 --- a/tests/e2e/test_multi_package.py +++ /dev/null @@ -1,560 +0,0 @@ -"""E2E tests for multiple package scenarios.""" - -from pathlib import Path - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope -from devsync.storage.package_tracker import PackageTracker - - -class TestMultiplePackages: - """Test installing and managing multiple packages.""" - - def test_install_multiple_packages_same_project(self, package_builder, test_project: Path) -> None: - """Test installing multiple packages to the same project.""" - # Create three different packages - pkg1 = package_builder( - name="python-pkg", - version="1.0.0", - instructions=[{"name": "python-style", "content": "# Python"}], - ) - pkg2 = package_builder( - name="django-pkg", - version="1.0.0", - instructions=[{"name": "django-models", "content": "# Django"}], - ) - pkg3 = package_builder( - name="testing-pkg", - version="1.0.0", - instructions=[{"name": "pytest-guide", "content": "# Testing"}], - ) - - # Install all three - result1 = install_package(pkg1, test_project, AIToolType.CLAUDE) - result2 = install_package(pkg2, test_project, AIToolType.CLAUDE) - result3 = install_package(pkg3, test_project, AIToolType.CLAUDE) - - assert all([result1.success, result2.success, result3.success]) - - # Verify all files exist - assert (test_project / ".claude/rules/python-style.md").exists() - assert (test_project / ".claude/rules/django-models.md").exists() - assert (test_project / ".claude/rules/pytest-guide.md").exists() - - # Verify all tracked - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - assert len(packages) == 3 - - names = {p.package_name for p in packages} - assert names == {"python-pkg", "django-pkg", "testing-pkg"} - - def test_packages_with_overlapping_namespaces(self, package_builder, test_project: Path) -> None: - """Test packages from the same namespace but different names.""" - pkg1 = package_builder( - name="base-pkg", - version="1.0.0", - instructions=[{"name": "base", "content": "# Base"}], - ) - pkg2 = package_builder( - name="advanced-pkg", - version="1.0.0", - instructions=[{"name": "advanced", "content": "# Advanced"}], - ) - - # Both have same namespace but different names - # (package_builder sets namespace to test/{name}) - - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 2 - assert {p.package_name for p in packages} == {"base-pkg", "advanced-pkg"} - - def test_update_one_package_leaves_others_unchanged(self, package_builder, test_project: Path) -> None: - """Test that updating one package doesn't affect others.""" - # Install two packages - pkg1_v1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1 v1.0"}], - ) - pkg2_v1 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "guide2", "content": "# Pkg2 v1.0"}], - ) - - install_package(pkg1_v1, test_project, AIToolType.CLAUDE) - install_package(pkg2_v1, test_project, AIToolType.CLAUDE) - - # Update pkg1 - pkg1_v2 = package_builder( - name="pkg1", - version="2.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1 v2.0"}], - ) - - install_package( - pkg1_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # Verify pkg1 updated - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg1_record = tracker.get_package("pkg1", InstallationScope.PROJECT) - pkg2_record = tracker.get_package("pkg2", InstallationScope.PROJECT) - - assert pkg1_record.version == "2.0.0" - assert pkg2_record.version == "1.0.0" # Unchanged - - # Verify file contents - assert "v2.0" in (test_project / ".claude/rules/guide1.md").read_text() - assert "v1.0" in (test_project / ".claude/rules/guide2.md").read_text() - - def test_uninstall_one_package_leaves_others_installed(self, package_builder, test_project: Path) -> None: - """Test that uninstalling one package doesn't affect others.""" - pkg1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1"}], - ) - pkg2 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "guide2", "content": "# Pkg2"}], - ) - pkg3 = package_builder( - name="pkg3", - version="1.0.0", - instructions=[{"name": "guide3", "content": "# Pkg3"}], - ) - - # Install all - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - install_package(pkg3, test_project, AIToolType.CLAUDE) - - # Uninstall pkg2 - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg2_record = tracker.get_package("pkg2", InstallationScope.PROJECT) - - for component in pkg2_record.components: - file_path = test_project / component.installed_path - if file_path.exists(): - file_path.unlink() - - tracker.remove_package("pkg2", InstallationScope.PROJECT) - - # Verify pkg1 and pkg3 still exist - assert (test_project / ".claude/rules/guide1.md").exists() - assert not (test_project / ".claude/rules/guide2.md").exists() - assert (test_project / ".claude/rules/guide3.md").exists() - - packages = tracker.get_installed_packages() - assert len(packages) == 2 - assert {p.package_name for p in packages} == {"pkg1", "pkg3"} - - -class TestPackageConflicts: - """Test conflicts between different packages.""" - - def test_packages_with_same_instruction_name(self, package_builder, test_project: Path) -> None: - """Test two packages with instructions of the same name.""" - pkg1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "style-guide", "content": "# Pkg1 Style"}], - ) - pkg2 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "style-guide", "content": "# Pkg2 Style"}], - ) - - # Install pkg1 - result1 = install_package(pkg1, test_project, AIToolType.CLAUDE) - assert result1.success is True - - # Install pkg2 with SKIP (preserves pkg1's file) - result2 = install_package( - pkg2, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result2.success is True - assert result2.skipped_count > 0 - - # Verify pkg1's version is preserved - content = (test_project / ".claude/rules/style-guide.md").read_text() - assert "Pkg1 Style" in content - - def test_packages_with_same_instruction_name_rename(self, package_builder, test_project: Path) -> None: - """Test RENAME strategy with conflicting instruction names.""" - pkg1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "config", "content": "# Pkg1 Config"}], - ) - pkg2 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "config", "content": "# Pkg2 Config"}], - ) - - # Install both with RENAME - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package( - pkg2, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Should have both files - assert (test_project / ".claude/rules/config.md").exists() - assert (test_project / ".claude/rules/config-1.md").exists() - - content1 = (test_project / ".claude/rules/config.md").read_text() - content2 = (test_project / ".claude/rules/config-1.md").read_text() - - assert "Pkg1" in content1 - assert "Pkg2" in content2 - - def test_package_with_resource_conflicts_with_project_file(self, package_builder, test_project: Path) -> None: - """Test package resource conflicting with existing project file.""" - # Create project file - (test_project / ".gitignore").write_text("# Project gitignore\n*.pyc\n") - - # Create package with .gitignore - pkg = package_builder( - name="pkg", - version="1.0.0", - resources=[{"name": ".gitignore", "content": "# Package gitignore\nnode_modules/\n"}], - ) - - # Install with SKIP (preserves project file) - result = install_package( - pkg, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.SKIP, - ) - - assert result.success is True - - # Project file preserved - content = (test_project / ".gitignore").read_text() - assert "*.pyc" in content - assert "node_modules" not in content - - -class TestPackageDependencies: - """Test scenarios involving package dependencies.""" - - def test_install_base_then_extension_package(self, package_builder, test_project: Path) -> None: - """Test installing a base package followed by an extension.""" - # Base package - base_pkg = package_builder( - name="python-base", - version="1.0.0", - instructions=[ - {"name": "python-basics", "content": "# Python basics"}, - {"name": "python-style", "content": "# PEP 8"}, - ], - ) - - # Extension package (assumes base is installed) - extension_pkg = package_builder( - name="python-advanced", - version="1.0.0", - instructions=[ - {"name": "async-patterns", "content": "# Async programming"}, - {"name": "type-hints", "content": "# Advanced typing"}, - ], - ) - - # Install both - install_package(base_pkg, test_project, AIToolType.CLAUDE) - install_package(extension_pkg, test_project, AIToolType.CLAUDE) - - # All instructions should exist - assert (test_project / ".claude/rules/python-basics.md").exists() - assert (test_project / ".claude/rules/python-style.md").exists() - assert (test_project / ".claude/rules/async-patterns.md").exists() - assert (test_project / ".claude/rules/type-hints.md").exists() - - tracker = PackageTracker(test_project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - assert len(packages) == 2 - - def test_company_team_personal_package_layering(self, package_builder, tmp_path: Path) -> None: - """Test realistic scenario: company + team + personal packages.""" - project = tmp_path / "project" - project.mkdir() - - # Company-wide package (security policies) - company_pkg = package_builder( - name="company-security", - version="1.0.0", - instructions=[ - {"name": "security-policy", "content": "# Company security"}, - ], - ) - - # Team package (backend practices) - team_pkg = package_builder( - name="team-backend", - version="1.0.0", - instructions=[ - {"name": "api-standards", "content": "# API standards"}, - {"name": "database-patterns", "content": "# DB patterns"}, - ], - ) - - # Personal package (preferred tools) - personal_pkg = package_builder( - name="personal-tools", - version="1.0.0", - instructions=[ - {"name": "my-shortcuts", "content": "# Personal shortcuts"}, - ], - ) - - # Install all three (simulating different scopes) - install_package(company_pkg, project, AIToolType.CLAUDE) - install_package(team_pkg, project, AIToolType.CLAUDE) - install_package(personal_pkg, project, AIToolType.CLAUDE) - - # Verify all coexist - tracker = PackageTracker(project / ".devsync/packages.json") - packages = tracker.get_installed_packages() - assert len(packages) == 3 - - names = {p.package_name for p in packages} - assert names == {"company-security", "team-backend", "personal-tools"} - - -class TestPackageOrdering: - """Test installation order and its effects.""" - - def test_installation_order_matters_for_conflicts(self, package_builder, test_project: Path) -> None: - """Test that installation order matters when files conflict.""" - pkg_a = package_builder( - name="pkg-a", - version="1.0.0", - instructions=[{"name": "shared", "content": "# From Package A"}], - ) - pkg_b = package_builder( - name="pkg-b", - version="1.0.0", - instructions=[{"name": "shared", "content": "# From Package B"}], - ) - - # Test A then B with SKIP - install_package(pkg_a, test_project, AIToolType.CLAUDE) - install_package( - pkg_b, - test_project, - AIToolType.CLAUDE, - conflict_resolution=ConflictResolution.SKIP, - ) - - content = (test_project / ".claude/rules/shared.md").read_text() - assert "Package A" in content # First one wins - - def test_reinstall_all_in_different_order(self, package_builder, test_project: Path) -> None: - """Test reinstalling multiple packages in different order.""" - pkg1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1"}], - ) - pkg2 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "guide2", "content": "# Pkg2"}], - ) - pkg3 = package_builder( - name="pkg3", - version="1.0.0", - instructions=[{"name": "guide3", "content": "# Pkg3"}], - ) - - # Install in order 1, 2, 3 - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - install_package(pkg3, test_project, AIToolType.CLAUDE) - - # Get tracker - tracker = PackageTracker(test_project / ".devsync/packages.json") - - # Reinstall in order 3, 1, 2 - install_package( - pkg3, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - install_package( - pkg1, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - install_package( - pkg2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # All should still be installed - packages2 = tracker.get_installed_packages() - assert len(packages2) == 3 - - # Order in tracker may differ but all exist - assert {p.package_name for p in packages2} == {"pkg1", "pkg2", "pkg3"} - - -class TestPackageUpdates: - """Test updating multiple packages.""" - - def test_update_all_packages_to_latest(self, package_builder, test_project: Path) -> None: - """Test updating all installed packages to newer versions.""" - # Install v1.0.0 of three packages - pkg1_v1 = package_builder( - name="pkg1", - version="1.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1 v1.0"}], - ) - pkg2_v1 = package_builder( - name="pkg2", - version="1.0.0", - instructions=[{"name": "guide2", "content": "# Pkg2 v1.0"}], - ) - pkg3_v1 = package_builder( - name="pkg3", - version="1.0.0", - instructions=[{"name": "guide3", "content": "# Pkg3 v1.0"}], - ) - - install_package(pkg1_v1, test_project, AIToolType.CLAUDE) - install_package(pkg2_v1, test_project, AIToolType.CLAUDE) - install_package(pkg3_v1, test_project, AIToolType.CLAUDE) - - # Update all to v2.0.0 - pkg1_v2 = package_builder( - name="pkg1", - version="2.0.0", - instructions=[{"name": "guide1", "content": "# Pkg1 v2.0"}], - ) - pkg2_v2 = package_builder( - name="pkg2", - version="2.0.0", - instructions=[{"name": "guide2", "content": "# Pkg2 v2.0"}], - ) - pkg3_v2 = package_builder( - name="pkg3", - version="2.0.0", - instructions=[{"name": "guide3", "content": "# Pkg3 v2.0"}], - ) - - install_package( - pkg1_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - install_package( - pkg2_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - install_package( - pkg3_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # Verify all updated - tracker = PackageTracker(test_project / ".devsync/packages.json") - for name in ["pkg1", "pkg2", "pkg3"]: - record = tracker.get_package(name, InstallationScope.PROJECT) - assert record.version == "2.0.0" - - def test_selective_package_updates(self, package_builder, test_project: Path) -> None: - """Test updating only some packages while leaving others at old versions.""" - # Install three packages - pkg1 = package_builder( - name="stable-pkg", - version="1.0.0", - instructions=[{"name": "stable", "content": "# Stable v1.0"}], - ) - pkg2 = package_builder( - name="beta-pkg", - version="1.0.0", - instructions=[{"name": "beta", "content": "# Beta v1.0"}], - ) - pkg3 = package_builder( - name="experimental-pkg", - version="1.0.0", - instructions=[{"name": "experimental", "content": "# Exp v1.0"}], - ) - - install_package(pkg1, test_project, AIToolType.CLAUDE) - install_package(pkg2, test_project, AIToolType.CLAUDE) - install_package(pkg3, test_project, AIToolType.CLAUDE) - - # Update only beta and experimental - pkg2_v2 = package_builder( - name="beta-pkg", - version="2.0.0", - instructions=[{"name": "beta", "content": "# Beta v2.0"}], - ) - pkg3_v2 = package_builder( - name="experimental-pkg", - version="2.0.0", - instructions=[{"name": "experimental", "content": "# Exp v2.0"}], - ) - - install_package( - pkg2_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - install_package( - pkg3_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # Verify selective updates - tracker = PackageTracker(test_project / ".devsync/packages.json") - stable = tracker.get_package("stable-pkg", InstallationScope.PROJECT) - beta = tracker.get_package("beta-pkg", InstallationScope.PROJECT) - experimental = tracker.get_package("experimental-pkg", InstallationScope.PROJECT) - - assert stable.version == "1.0.0" # Not updated - assert beta.version == "2.0.0" # Updated - assert experimental.version == "2.0.0" # Updated diff --git a/tests/e2e/test_version_management.py b/tests/e2e/test_version_management.py deleted file mode 100644 index 8b3eb4a..0000000 --- a/tests/e2e/test_version_management.py +++ /dev/null @@ -1,425 +0,0 @@ -"""E2E tests for package version management and updates.""" - -import subprocess -from pathlib import Path - -from devsync.cli.package_install import install_package -from devsync.core.models import AIToolType, ConflictResolution, InstallationScope -from devsync.storage.package_tracker import PackageTracker - - -class TestVersionUpdates: - """Test updating packages to newer versions.""" - - def test_update_patch_version(self, package_builder, test_project: Path) -> None: - """Test updating from 1.0.0 to 1.0.1 (patch update).""" - # Install v1.0.0 - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide", "content": "# Guide v1.0.0\n\nOriginal content"}, - ], - ) - result1 = install_package(pkg_v1, test_project, AIToolType.CLAUDE) - assert result1.success is True - - # Create v1.0.1 with bug fix - pkg_v101 = package_builder( - name="test-pkg", - version="1.0.1", - instructions=[ - {"name": "guide", "content": "# Guide v1.0.1\n\nBug fix applied"}, - ], - ) - - # Update - result2 = install_package( - pkg_v101, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result2.success is True - - # Verify version updated - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.1" - - # Verify content updated - guide_path = test_project / ".claude/rules/guide.md" - content = guide_path.read_text() - assert "1.0.1" in content - assert "Bug fix" in content - - def test_update_minor_version(self, package_builder, test_project: Path) -> None: - """Test updating from 1.0.0 to 1.1.0 (minor update with new features).""" - # Install v1.0.0 - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "basics", "content": "# Basics\n\nBasic guide"}, - ], - ) - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # Create v1.1.0 with new instruction - pkg_v11 = package_builder( - name="test-pkg", - version="1.1.0", - instructions=[ - {"name": "basics", "content": "# Basics\n\nUpdated basic guide"}, - {"name": "advanced", "content": "# Advanced\n\nNew advanced features"}, - ], - ) - - # Update - result = install_package( - pkg_v11, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result.success is True - - # Verify both instructions exist - assert (test_project / ".claude/rules/basics.md").exists() - assert (test_project / ".claude/rules/advanced.md").exists() - - # Verify version - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.1.0" - assert len(pkg_record.components) == 2 - - def test_update_major_version(self, package_builder, test_project: Path) -> None: - """Test updating from 1.0.0 to 2.0.0 (major update with breaking changes).""" - # Install v1.0.0 - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "old-api", "content": "# Old API\n\nDeprecated API"}, - ], - hooks=[ - {"name": "old-hook", "content": "#!/bin/bash\necho 'old'\n"}, - ], - ) - install_package(pkg_v1, test_project, AIToolType.CLAUDE) - - # Create v2.0.0 with completely new structure - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[ - {"name": "new-api", "content": "# New API\n\nModern API"}, - ], - commands=[ - {"name": "new-command", "content": "#!/bin/bash\necho 'new'\n"}, - ], - ) - - # Update (breaking changes) - result = install_package( - pkg_v2, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result.success is True - - # Verify new structure - assert (test_project / ".claude/rules/new-api.md").exists() - assert (test_project / ".claude/commands/new-command.sh").exists() - - # Old files may still exist (not removed automatically) - # This is expected behavior - major updates require manual cleanup - - # Verify version - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "2.0.0" - - def test_downgrade_version(self, package_builder, test_project: Path) -> None: - """Test downgrading from 2.0.0 to 1.0.0.""" - # Install v2.0.0 - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[ - {"name": "guide", "content": "# Guide v2.0.0\n\nLatest features"}, - ], - ) - install_package(pkg_v2, test_project, AIToolType.CLAUDE) - - # Downgrade to v1.0.0 - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[ - {"name": "guide", "content": "# Guide v1.0.0\n\nStable version"}, - ], - ) - - result = install_package( - pkg_v1, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result.success is True - - # Verify downgrade - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.0" - - # Verify content downgraded - content = (test_project / ".claude/rules/guide.md").read_text() - assert "1.0.0" in content - - -class TestGitVersionTags: - """Test installing specific versions from git tags.""" - - def test_install_from_specific_tag(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test installing a package from a specific git tag.""" - # Create v1.0.0 - pkg_path = package_builder( - name="git-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0"}], - as_git=True, - ) - - # Create v1.1.0 (new commit and tag) - guide_path = pkg_path / "instructions/guide.md" - guide_path.write_text("# v1.1.0\n\nUpdated") - - # Update manifest - manifest_path = pkg_path / "ai-config-kit-package.yaml" - manifest = manifest_path.read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0") - manifest_path.write_text(manifest) - - subprocess.run(["git", "add", "."], cwd=pkg_path, check=True) - subprocess.run( - ["git", "commit", "-m", "Release v1.1.0"], - cwd=pkg_path, - check=True, - ) - subprocess.run(["git", "tag", "v1.1.0"], cwd=pkg_path, check=True) - - # Clone to temp location for tag checkout - clone_path = tmp_path / "clone" - subprocess.run( - ["git", "clone", str(pkg_path), str(clone_path)], - check=True, - capture_output=True, - ) - - # Checkout v1.0.0 - subprocess.run( - ["git", "checkout", "v1.0.0"], - cwd=clone_path, - check=True, - capture_output=True, - ) - - # Install from v1.0.0 - result = install_package(clone_path, test_project, AIToolType.CLAUDE) - assert result.success is True - - # Verify installed v1.0.0 - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("git-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.0" - - content = (test_project / ".claude/rules/guide.md").read_text() - assert "v1.0.0" in content - assert "v1.1.0" not in content - - def test_update_by_installing_newer_tag(self, package_builder, test_project: Path, tmp_path: Path) -> None: - """Test updating by installing from a newer tag.""" - # Create repo with multiple versions - pkg_path = package_builder( - name="versioned-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0"}], - as_git=True, - ) - - # Install v1.0.0 - result1 = install_package(pkg_path, test_project, AIToolType.CLAUDE) - assert result1.success is True - - # Create v1.1.0 - (pkg_path / "instructions/guide.md").write_text("# v1.1.0\n") - manifest = (pkg_path / "ai-config-kit-package.yaml").read_text() - manifest = manifest.replace("version: 1.0.0", "version: 1.1.0") - (pkg_path / "ai-config-kit-package.yaml").write_text(manifest) - - subprocess.run(["git", "add", "."], cwd=pkg_path, check=True) - subprocess.run(["git", "commit", "-m", "v1.1.0"], cwd=pkg_path, check=True) - subprocess.run(["git", "tag", "v1.1.0"], cwd=pkg_path, check=True) - - # Update to v1.1.0 - result2 = install_package( - pkg_path, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result2.success is True - - # Verify update - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("versioned-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.1.0" - - -class TestVersionConflicts: - """Test handling version conflicts and requirements.""" - - def test_install_older_version_over_newer_warns(self, package_builder, test_project: Path) -> None: - """Test installing an older version over a newer one.""" - # Install v2.0.0 - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[{"name": "guide", "content": "# v2.0.0"}], - ) - install_package(pkg_v2, test_project, AIToolType.CLAUDE) - - # Install v1.0.0 (downgrade) - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1.0.0"}], - ) - - # Should succeed but is a downgrade - result = install_package( - pkg_v1, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result.success is True - - # Verify downgrade occurred - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.0" - - def test_multiple_versions_in_different_projects(self, package_builder, tmp_path: Path) -> None: - """Test installing different versions in different projects.""" - # Create two projects - project1 = tmp_path / "project1" - project1.mkdir() - project2 = tmp_path / "project2" - project2.mkdir() - - # Create two versions - pkg_v1 = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# v1"}], - ) - pkg_v2 = package_builder( - name="test-pkg", - version="2.0.0", - instructions=[{"name": "guide", "content": "# v2"}], - ) - - # Install different versions - install_package(pkg_v1, project1, AIToolType.CLAUDE) - install_package(pkg_v2, project2, AIToolType.CLAUDE) - - # Verify each has correct version - tracker1 = PackageTracker(project1 / ".devsync/packages.json") - tracker2 = PackageTracker(project2 / ".devsync/packages.json") - - pkg1_record = tracker1.get_package("test-pkg", InstallationScope.PROJECT) - pkg2_record = tracker2.get_package("test-pkg", InstallationScope.PROJECT) - - assert pkg1_record.version == "1.0.0" - assert pkg2_record.version == "2.0.0" - - # Verify content - content1 = (project1 / ".claude/rules/guide.md").read_text() - content2 = (project2 / ".claude/rules/guide.md").read_text() - - assert "v1" in content1 - assert "v2" in content2 - - -class TestPreReleaseVersions: - """Test handling pre-release versions.""" - - def test_install_alpha_version(self, package_builder, test_project: Path) -> None: - """Test installing an alpha pre-release version.""" - pkg = package_builder( - name="test-pkg", - version="2.0.0-alpha.1", - instructions=[{"name": "guide", "content": "# Alpha version"}], - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - assert result.success is True - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "2.0.0-alpha.1" - - def test_install_beta_then_release(self, package_builder, test_project: Path) -> None: - """Test upgrading from beta to final release.""" - # Install beta - pkg_beta = package_builder( - name="test-pkg", - version="1.0.0-beta.1", - instructions=[{"name": "guide", "content": "# Beta"}], - ) - install_package(pkg_beta, test_project, AIToolType.CLAUDE) - - # Upgrade to release - pkg_release = package_builder( - name="test-pkg", - version="1.0.0", - instructions=[{"name": "guide", "content": "# Release"}], - ) - result = install_package( - pkg_release, - test_project, - AIToolType.CLAUDE, - force=True, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - assert result.success is True - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "1.0.0" - - def test_install_rc_version(self, package_builder, test_project: Path) -> None: - """Test installing release candidate version.""" - pkg = package_builder( - name="test-pkg", - version="3.0.0-rc.2", - instructions=[{"name": "guide", "content": "# Release candidate"}], - ) - - result = install_package(pkg, test_project, AIToolType.CLAUDE) - assert result.success is True - - tracker = PackageTracker(test_project / ".devsync/packages.json") - pkg_record = tracker.get_package("test-pkg", InstallationScope.PROJECT) - assert pkg_record.version == "3.0.0-rc.2" diff --git a/tests/integration/packages/test_package_install.py b/tests/integration/packages/test_package_install.py deleted file mode 100644 index f98d806..0000000 --- a/tests/integration/packages/test_package_install.py +++ /dev/null @@ -1,567 +0,0 @@ -"""Integration tests for package installation workflow.""" - -from pathlib import Path - -import pytest - -from devsync.core.models import ( - AIToolType, - ComponentType, - InstallationScope, - InstallationStatus, -) -from devsync.storage.package_tracker import PackageTracker - - -@pytest.fixture -def sample_package_dir(tmp_path: Path) -> Path: - """Create a sample package for testing.""" - package_dir = tmp_path / "test-package" - package_dir.mkdir() - - # Create manifest - manifest_content = """name: test-package -version: 1.0.0 -description: Test package for integration testing -author: Test Author -license: MIT -namespace: test/repo - -components: - instructions: - - name: python-style - file: instructions/python-style.md - description: Python style guide - tags: [python, style] - - mcp_servers: - - name: filesystem - file: mcp/filesystem.json - description: Filesystem MCP server - credentials: - - name: BASE_PATH - description: Base path for filesystem access - required: true - - hooks: - - name: pre-commit - file: hooks/pre-commit.sh - description: Pre-commit formatting hook - hook_type: pre-commit - - commands: - - name: format - file: commands/format.sh - description: Format code command - command_type: shell - - resources: - - name: config - file: resources/config.json - description: Configuration file - checksum: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - size: 100 -""" - (package_dir / "ai-config-kit-package.yaml").write_text(manifest_content) - - # Create instruction file - (package_dir / "instructions").mkdir() - (package_dir / "instructions" / "python-style.md").write_text("# Python Style Guide\n\nFollow PEP 8 conventions.") - - # Create MCP config - (package_dir / "mcp").mkdir() - (package_dir / "mcp" / "filesystem.json").write_text( - '{"mcpServers": {"filesystem": {"command": "mcp-server-filesystem"}}}' - ) - - # Create hook - (package_dir / "hooks").mkdir() - (package_dir / "hooks" / "pre-commit.sh").write_text("#!/bin/bash\nblack .") - - # Create command - (package_dir / "commands").mkdir() - (package_dir / "commands" / "format.sh").write_text("#!/bin/bash\nblack .") - - # Create resource - (package_dir / "resources").mkdir() - (package_dir / "resources" / "config.json").write_text("{}") - - return package_dir - - -@pytest.fixture -def project_root(tmp_path: Path) -> Path: - """Create a temporary project root directory.""" - project_dir = tmp_path / "test-project" - project_dir.mkdir() - - # Initialize git repo to mark as project root - (project_dir / ".git").mkdir() - - return project_dir - - -class TestPackageInstall: - """Test package installation workflow.""" - - def test_install_package_with_all_components(self, sample_package_dir: Path, project_root: Path) -> None: - """ - Test installing a package with all component types. - - Verifies that: - - All compatible components are installed to correct IDE locations - - Installation is tracked in packages.json - - Installed files have correct content - - Installation status is COMPLETE - """ - from devsync.cli.package_install import install_package - - # Install package for Claude Code IDE (supports all components) - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - - # Verify installation succeeded - assert result.success is True - assert result.status == InstallationStatus.COMPLETE - assert result.installed_count == 5 # All 5 components - assert result.skipped_count == 0 - assert result.failed_count == 0 - - # Verify instruction installed - instruction_file = project_root / ".claude" / "rules" / "python-style.md" - assert instruction_file.exists() - assert "PEP 8" in instruction_file.read_text() - - # Verify MCP config installed (needs processing) - # MCP goes to global config, so just verify it was processed - assert result.components_installed[ComponentType.MCP_SERVER] == 1 - - # Verify hook installed - hook_file = project_root / ".claude" / "hooks" / "pre-commit.sh" - assert hook_file.exists() - assert "black" in hook_file.read_text() - - # Verify command installed - command_file = project_root / ".claude" / "commands" / "format.sh" - assert command_file.exists() - - # Verify resource installed - resource_file = project_root / "resources" / "config.json" - assert resource_file.exists() - - # Verify tracking - tracker = PackageTracker(project_root / ".devsync" / "packages.json") - record = tracker.get_package("test-package", InstallationScope.PROJECT) - - assert record is not None - assert record.package_name == "test-package" - assert record.version == "1.0.0" - assert record.status == InstallationStatus.COMPLETE - assert len(record.components) == 5 - - def test_install_package_ide_filtering(self, sample_package_dir: Path, project_root: Path) -> None: - """ - Test that unsupported components are skipped based on IDE capabilities. - - Verifies that: - - Only components supported by target IDE are installed - - Unsupported components are counted as skipped - - Installation status reflects partial installation if some skipped - """ - from devsync.cli.package_install import install_package - - # Install for Cursor (supports instructions, MCP servers, and resources) - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CURSOR, - scope=InstallationScope.PROJECT, - ) - - # Verify partial installation (some components filtered) - assert result.success is True - assert result.status == InstallationStatus.PARTIAL - assert result.installed_count == 3 # instruction + mcp + resource - assert result.skipped_count == 2 # hook, command skipped - assert result.failed_count == 0 - - # Verify instruction installed - instruction_file = project_root / ".cursor" / "rules" / "python-style.mdc" - assert instruction_file.exists() - - # Verify resource installed - resource_file = project_root / "resources" / "config.json" - assert resource_file.exists() - - # Verify unsupported components NOT installed - assert not (project_root / ".cursor" / "hooks").exists() - assert not (project_root / ".cursor" / "commands").exists() - - # Verify tracking shows partial installation - tracker = PackageTracker(project_root / ".devsync" / "packages.json") - record = tracker.get_package("test-package", InstallationScope.PROJECT) - - assert record is not None - assert record.status == InstallationStatus.PARTIAL - assert len(record.components) == 3 # instruction + mcp + resource tracked - - def test_install_package_already_exists(self, sample_package_dir: Path, project_root: Path) -> None: - """ - Test reinstalling a package that's already installed. - - Verifies that: - - Existing installation is detected - - User is prompted or auto-handled based on conflict strategy - - Installation record is updated (not duplicated) - - Updated timestamp reflects reinstall - """ - from devsync.cli.package_install import install_package - from devsync.core.models import ConflictResolution - - # First installation - result1 = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - assert result1.success is True - - # Get original timestamp - tracker = PackageTracker(project_root / ".devsync" / "packages.json") - record1 = tracker.get_package("test-package", InstallationScope.PROJECT) - original_time = record1.updated_at - - # Second installation (should detect existing) - result2 = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - conflict_resolution=ConflictResolution.OVERWRITE, - ) - - # Verify reinstall succeeded - assert result2.success is True - assert result2.is_reinstall is True - - # Verify tracking updated (not duplicated) - assert tracker.get_package_count() == 1 - record2 = tracker.get_package("test-package", InstallationScope.PROJECT) - assert record2.updated_at > original_time - - def test_install_package_with_conflict_skip(self, sample_package_dir: Path, project_root: Path) -> None: - """Test that SKIP conflict resolution preserves existing files.""" - from devsync.cli.package_install import install_package - from devsync.core.models import ConflictResolution - - # Create pre-existing file with different content - rules_dir = project_root / ".claude" / "rules" - rules_dir.mkdir(parents=True) - existing_file = rules_dir / "python-style.md" - existing_file.write_text("# Original Content") - - # Install with SKIP resolution - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - conflict_resolution=ConflictResolution.SKIP, - ) - - # Verify installation completed but instruction was skipped - assert result.success is True - assert result.skipped_count >= 1 - - # Verify original file unchanged - assert existing_file.read_text() == "# Original Content" - - def test_install_package_with_conflict_rename(self, sample_package_dir: Path, project_root: Path) -> None: - """Test that RENAME conflict resolution creates numbered copies.""" - from devsync.cli.package_install import install_package - from devsync.core.models import ConflictResolution - - # Create pre-existing file - rules_dir = project_root / ".claude" / "rules" - rules_dir.mkdir(parents=True) - existing_file = rules_dir / "python-style.md" - existing_file.write_text("# Original Content") - - # Install with RENAME resolution - install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - conflict_resolution=ConflictResolution.RENAME, - ) - - # Verify both files exist - assert existing_file.exists() - assert existing_file.read_text() == "# Original Content" - - # New file should have number suffix - renamed_file = rules_dir / "python-style-1.md" - assert renamed_file.exists() - assert "PEP 8" in renamed_file.read_text() - - def test_install_package_missing_manifest_fails(self, tmp_path: Path, project_root: Path) -> None: - """Test that installing package without manifest fails gracefully.""" - from devsync.cli.package_install import install_package - - # Create package without manifest - invalid_package = tmp_path / "invalid-package" - invalid_package.mkdir() - - # Should return failure result (not raise exception) - result = install_package( - package_path=invalid_package, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - - assert result.success is False - assert result.status == InstallationStatus.FAILED - assert "Manifest not found" in result.error_message or "not found" in result.error_message - - def test_install_package_invalid_manifest_fails(self, tmp_path: Path, project_root: Path) -> None: - """Test that invalid manifest causes installation to fail.""" - from devsync.cli.package_install import install_package - - # Create package with invalid manifest (missing required fields) - invalid_package = tmp_path / "invalid-package" - invalid_package.mkdir() - (invalid_package / "ai-config-kit-package.yaml").write_text("name: test\n# Missing version, description, etc.") - - # Should return failure result (validation errors) - result = install_package( - package_path=invalid_package, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - - assert result.success is False - assert result.status == InstallationStatus.FAILED - assert "missing required field" in result.error_message.lower() or "validation" in result.error_message.lower() - - def test_list_installed_packages(self, sample_package_dir: Path, project_root: Path) -> None: - """Test listing installed packages.""" - from devsync.cli.package_install import install_package - - # Install a package first - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - assert result.success is True - - # Now list packages - tracker = PackageTracker(project_root / ".devsync" / "packages.json") - packages = tracker.get_installed_packages() - - assert len(packages) == 1 - assert packages[0].package_name == "test-package" - assert packages[0].version == "1.0.0" - assert packages[0].status == InstallationStatus.COMPLETE - assert len(packages[0].components) == 5 - - def test_uninstall_package(self, sample_package_dir: Path, project_root: Path) -> None: - """Test uninstalling a package.""" - from devsync.cli.package_install import install_package - - # Install a package first - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - assert result.success is True - - # Verify files exist - instruction_file = project_root / ".claude" / "rules" / "python-style.md" - assert instruction_file.exists() - - # Uninstall - tracker = PackageTracker(project_root / ".devsync" / "packages.json") - package = tracker.get_package("test-package", InstallationScope.PROJECT) - assert package is not None - - # Remove files - for component in package.components: - file_path = project_root / component.installed_path - if file_path.exists(): - file_path.unlink() - - # Remove from tracker - success = tracker.remove_package("test-package", InstallationScope.PROJECT) - assert success is True - - # Verify removed - assert tracker.get_package("test-package", InstallationScope.PROJECT) is None - assert not instruction_file.exists() - - def test_installation_result_total_components(self) -> None: - """Test InstallationResult.total_components property.""" - from devsync.cli.package_install import InstallationResult - - result = InstallationResult( - success=True, - status=InstallationStatus.PARTIAL, - package_name="test", - version="1.0.0", - installed_count=5, - skipped_count=3, - failed_count=2, - ) - - assert result.total_components == 10 - - def test_install_mcp_component_with_conflict_skip(self, sample_package_dir: Path, project_root: Path) -> None: - """Test that MCP component installation skips on conflict.""" - from devsync.cli.package_install import install_package - from devsync.core.models import ConflictResolution - - # Modify package to only have MCP component - manifest_content = """name: mcp-only-package -version: 1.0.0 -description: MCP only package -author: Test Author -license: MIT -namespace: test/repo - -components: - mcp_servers: - - name: filesystem - file: mcp/filesystem.json - description: Filesystem MCP server -""" - (sample_package_dir / "ai-config-kit-package.yaml").write_text(manifest_content) - - # First install - result1 = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - assert result1.success is True - assert result1.installed_count == 1 - - # Second install with SKIP - should skip the MCP component - result2 = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - conflict_resolution=ConflictResolution.SKIP, - ) - assert result2.success is True - assert result2.skipped_count >= 1 - - def test_install_instruction_component_exception_handling( - self, sample_package_dir: Path, project_root: Path - ) -> None: - """Test exception handling in instruction component installation.""" - from unittest.mock import patch - - from devsync.cli.package_install import install_package - - # Mock translator to raise exception - with patch("devsync.cli.package_install.get_translator") as mock_translator: - mock_translator.return_value.translate_instruction.side_effect = RuntimeError("Translation error") - - result = install_package( - package_path=sample_package_dir, - project_root=project_root, - target_ide=AIToolType.CLAUDE, - scope=InstallationScope.PROJECT, - ) - - # Should complete but skip the failed instruction - assert result.success is True - assert result.skipped_count >= 1 - - def test_install_mcp_component_exception_handling(self, tmp_path: Path, project_root: Path) -> None: - """Test exception handling in MCP component installation.""" - from unittest.mock import patch - - from devsync.cli.package_install import install_package - - # Create package with MCP only - mcp_package = tmp_path / "mcp-package" - mcp_package.mkdir() - - manifest = """name: mcp-test -version: 1.0.0 -description: MCP test -author: Test -license: MIT -namespace: test/repo - -components: - mcp_servers: - - name: test-server - file: mcp/server.json - description: Test server -""" - (mcp_package / "ai-config-kit-package.yaml").write_text(manifest) - (mcp_package / "mcp").mkdir() - (mcp_package / "mcp" / "server.json").write_text('{"server": "config"}') - - # Mock translator to raise exception - with patch("devsync.cli.package_install.get_translator") as mock_translator: - mock_translator.return_value.translate_mcp_server.side_effect = RuntimeError("MCP error") - - result = install_package(package_path=mcp_package, project_root=project_root, target_ide=AIToolType.CLAUDE) - - # Should complete but skip the failed MCP - assert result.success is True - assert result.skipped_count >= 1 - - def test_install_hook_component_exception_handling(self, tmp_path: Path, project_root: Path) -> None: - """Test exception handling in hook component installation.""" - from unittest.mock import patch - - from devsync.cli.package_install import install_package - - # Create package with hook only - hook_package = tmp_path / "hook-package" - hook_package.mkdir() - - manifest = """name: hook-test -version: 1.0.0 -description: Hook test -author: Test -license: MIT -namespace: test/repo - -components: - hooks: - - name: test-hook - file: hooks/test.sh - description: Test hook - hook_type: pre-commit -""" - (hook_package / "ai-config-kit-package.yaml").write_text(manifest) - (hook_package / "hooks").mkdir() - (hook_package / "hooks" / "test.sh").write_text("#!/bin/bash\necho test") - - # Mock translator to raise exception - with patch("devsync.cli.package_install.get_translator") as mock_translator: - mock_translator.return_value.translate_hook.side_effect = RuntimeError("Hook error") - - result = install_package(package_path=hook_package, project_root=project_root, target_ide=AIToolType.CLAUDE) - - # Should complete but skip the failed hook - assert result.success is True - assert result.skipped_count >= 1 diff --git a/tests/integration/test_library.py b/tests/integration/test_library.py deleted file mode 100644 index 904324f..0000000 --- a/tests/integration/test_library.py +++ /dev/null @@ -1,491 +0,0 @@ -"""Integration tests for library management.""" - -from pathlib import Path - -from devsync.core.models import LibraryInstruction -from devsync.storage.library import LibraryManager - - -class TestLibraryManager: - """Test library management functionality.""" - - def test_create_library_manager(self, temp_dir: Path): - """Test creating library manager.""" - library_dir = temp_dir / "library" - manager = LibraryManager(library_dir) - - assert manager.library_dir == library_dir - assert library_dir.exists() - - def test_generate_namespace(self, temp_dir: Path): - """Test namespace generation from URLs.""" - manager = LibraryManager(temp_dir) - - # Test GitHub URL - namespace = manager.get_repo_namespace("https://github.com/company/instructions", "Company Instructions") - assert "github" in namespace - assert "company" in namespace - - # Test local path - local_namespace = manager.get_repo_namespace("/local/path/instructions", "Local Instructions") - assert "local" in local_namespace - - def test_add_repository(self, temp_dir: Path): - """Test adding a repository to library.""" - manager = LibraryManager(temp_dir / "library") - - instructions = [ - LibraryInstruction( - id="test/python-style", - name="python-style", - description="Python style guide", - repo_namespace="test_repo", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Test Author", - version="1.0.0", - file_path="/path/to/file.md", - tags=["python", "style"], - ) - ] - - repo = manager.add_repository( - repo_name="Test Repo", - repo_description="Test repository", - repo_url="https://github.com/test/repo", - repo_author="Test Author", - repo_version="1.0.0", - instructions=instructions, - ) - - assert repo.name == "Test Repo" - assert len(repo.instructions) == 1 - assert repo.namespace is not None - - def test_list_repositories(self, temp_dir: Path): - """Test listing repositories.""" - manager = LibraryManager(temp_dir / "library") - - # Add two repositories - for i in range(2): - manager.add_repository( - repo_name=f"Repo {i}", - repo_description=f"Description {i}", - repo_url=f"https://github.com/test/repo{i}", - repo_author="Author", - repo_version="1.0.0", - instructions=[], - ) - - repos = manager.list_repositories() - assert len(repos) == 2 - - def test_get_repository(self, temp_dir: Path): - """Test getting repository by namespace.""" - manager = LibraryManager(temp_dir / "library") - - repo = manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=[], - ) - - retrieved = manager.get_repository(repo.namespace) - assert retrieved is not None - assert retrieved.name == "Test Repo" - - def test_remove_repository(self, temp_dir: Path): - """Test removing repository from library.""" - manager = LibraryManager(temp_dir / "library") - - repo = manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=[], - ) - - result = manager.remove_repository(repo.namespace) - assert result is True - - retrieved = manager.get_repository(repo.namespace) - assert retrieved is None - - def test_list_instructions(self, temp_dir: Path): - """Test listing all instructions across repositories.""" - manager = LibraryManager(temp_dir / "library") - - instructions1 = [ - LibraryInstruction( - id="repo1/inst1", - name="inst1", - description="Instruction 1", - repo_namespace="repo1", - repo_url="https://github.com/test/repo1", - repo_name="Repo 1", - author="Author", - version="1.0.0", - file_path="/path/to/inst1.md", - ) - ] - - instructions2 = [ - LibraryInstruction( - id="repo2/inst2", - name="inst2", - description="Instruction 2", - repo_namespace="repo2", - repo_url="https://github.com/test/repo2", - repo_name="Repo 2", - author="Author", - version="1.0.0", - file_path="/path/to/inst2.md", - ) - ] - - manager.add_repository( - repo_name="Repo 1", - repo_description="First repo", - repo_url="https://github.com/test/repo1", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions1, - ) - - manager.add_repository( - repo_name="Repo 2", - repo_description="Second repo", - repo_url="https://github.com/test/repo2", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions2, - ) - - all_instructions = manager.list_instructions() - assert len(all_instructions) == 2 - - def test_get_instruction(self, temp_dir: Path): - """Test getting instruction by ID.""" - manager = LibraryManager(temp_dir / "library") - - instructions = [ - LibraryInstruction( - id="test/python-style", - name="python-style", - description="Python style guide", - repo_namespace="test", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/file.md", - ) - ] - - manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions, - ) - - inst = manager.get_instruction("test/python-style") - assert inst is not None - assert inst.name == "python-style" - - def test_get_instructions_by_name(self, temp_dir: Path): - """Test getting instructions by name (may be multiple).""" - manager = LibraryManager(temp_dir / "library") - - # Add same instruction name from different repos - for i in range(2): - instructions = [ - LibraryInstruction( - id=f"repo{i}/python-style", - name="python-style", - description=f"Python style guide {i}", - repo_namespace=f"repo{i}", - repo_url=f"https://github.com/test/repo{i}", - repo_name=f"Repo {i}", - author="Author", - version="1.0.0", - file_path=f"/path/to/file{i}.md", - ) - ] - - manager.add_repository( - repo_name=f"Repo {i}", - repo_description=f"Repo {i}", - repo_url=f"https://github.com/test/repo{i}", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions, - ) - - matches = manager.get_instructions_by_name("python-style") - assert len(matches) == 2 - - def test_search_instructions(self, temp_dir: Path): - """Test searching instructions with filters.""" - manager = LibraryManager(temp_dir / "library") - - instructions = [ - LibraryInstruction( - id="test/python-style", - name="python-style", - description="Python style guidelines", - repo_namespace="test", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/file.md", - tags=["python", "style"], - ), - LibraryInstruction( - id="test/javascript-style", - name="javascript-style", - description="JavaScript style guidelines", - repo_namespace="test", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/file2.md", - tags=["javascript", "style"], - ), - ] - - manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions, - ) - - # Search by query - results = manager.search_instructions(query="python") - assert len(results) == 1 - assert results[0].name == "python-style" - - # Search by tag - results = manager.search_instructions(tags=["javascript"]) - assert len(results) == 1 - assert results[0].name == "javascript-style" - - # Search by repo namespace - results = manager.search_instructions(repo_namespace="test") - assert len(results) == 2 - - def test_generate_alias_fallback(self, temp_dir: Path): - """Test generate_alias fallback for non-URL repo names.""" - manager = LibraryManager(temp_dir / "library") - - # Test with git@ URL (non-https) - alias = manager.generate_alias("git@github.com:company/repo.git", "My Repo") - # Should fallback to sanitized repo name - assert alias == "my-repo" - - def test_remove_repository_not_found(self, temp_dir: Path): - """Test removing non-existent repository.""" - manager = LibraryManager(temp_dir / "library") - - # Try to remove non-existent namespace - result = manager.remove_repository("nonexistent-namespace") - assert result is False - - def test_get_repository_by_url(self, temp_dir: Path): - """Test getting repository by source URL.""" - manager = LibraryManager(temp_dir / "library") - - # Add repository with URL - _repo = manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=[], - ) - - # Find by URL - found = manager.get_repository_by_url("https://github.com/test/repo") - assert found is not None - assert found.name == "Test Repo" - - def test_get_repository_by_url_local_path(self, temp_dir: Path): - """Test getting repository by local path URL.""" - manager = LibraryManager(temp_dir / "library") - - # Add repository with local path - local_path = temp_dir / "my-repo" - local_path.mkdir() - - _repo = manager.add_repository( - repo_name="Local Repo", - repo_description="Test", - repo_url=str(local_path), - repo_author="Author", - repo_version="1.0.0", - instructions=[], - ) - - # Find by path (should normalize to absolute) - found = manager.get_repository_by_url(str(local_path)) - assert found is not None - assert found.name == "Local Repo" - - def test_get_repository_by_url_not_found(self, temp_dir: Path): - """Test get_repository_by_url returns None when URL not found.""" - manager = LibraryManager(temp_dir / "library") - - # Try to find non-existent repository - found = manager.get_repository_by_url("https://github.com/nonexistent/repo") - assert found is None - - def test_get_instruction_not_found(self, temp_dir: Path): - """Test get_instruction returns None when not found.""" - manager = LibraryManager(temp_dir / "library") - - # No repositories added - inst = manager.get_instruction("nonexistent/instruction") - assert inst is None - - def test_get_instructions_by_source_and_name(self, temp_dir: Path): - """Test getting instructions by source alias and name.""" - manager = LibraryManager(temp_dir / "library") - - instructions = [ - LibraryInstruction( - id="test/python-style", - name="python-style", - description="Python style guide", - repo_namespace="test", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/file.md", - ) - ] - - # Add repository with specific alias - manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions, - alias="test-repo", - ) - - # Find by source alias and name - found = manager.get_instructions_by_source_and_name("test-repo", "python-style") - assert len(found) == 1 - assert found[0].name == "python-style" - - def test_get_instructions_by_source_and_name_not_found(self, temp_dir: Path): - """Test get_instructions_by_source_and_name with no matches.""" - manager = LibraryManager(temp_dir / "library") - - # No repositories added - found = manager.get_instructions_by_source_and_name("nonexistent", "python-style") - assert len(found) == 0 - - def test_get_instruction_file_path(self, temp_dir: Path): - """Test getting instruction file path.""" - manager = LibraryManager(temp_dir / "library") - - instructions = [ - LibraryInstruction( - id="test/python-style", - name="python-style", - description="Python style guide", - repo_namespace="test", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/file.md", - ) - ] - - manager.add_repository( - repo_name="Test Repo", - repo_description="Test", - repo_url="https://github.com/test/repo", - repo_author="Author", - repo_version="1.0.0", - instructions=instructions, - ) - - # Get file path - path = manager.get_instruction_file_path("test/python-style") - assert path is not None - assert path.as_posix() == "/path/to/file.md" - - def test_get_instruction_file_path_not_found(self, temp_dir: Path): - """Test get_instruction_file_path returns None when not found.""" - manager = LibraryManager(temp_dir / "library") - - # No repositories added - path = manager.get_instruction_file_path("nonexistent/instruction") - assert path is None - - def test_get_versioned_namespace(self, temp_dir: Path): - """Test generating versioned namespace.""" - manager = LibraryManager(temp_dir / "library") - - # Test with tag - namespace = manager.get_versioned_namespace("https://github.com/test/repo", "v1.0.0") - assert "@v1.0.0" in namespace - assert "github" in namespace - - # Test with branch with slashes - namespace = manager.get_versioned_namespace("https://github.com/test/repo", "feature/new-feature") - assert "@feature_new-feature" in namespace - - def test_list_repository_versions(self, temp_dir: Path): - """Test listing all versions of a repository.""" - manager = LibraryManager(temp_dir / "library") - - # Add multiple versions of same repo - base_url = "https://github.com/test/repo" - - # Add v1.0.0 - manager.add_repository( - repo_name="Test Repo v1", - repo_description="Version 1", - repo_url=base_url, - repo_author="Author", - repo_version="1.0.0", - instructions=[], - namespace=manager.get_versioned_namespace(base_url, "v1.0.0"), - ) - - # Add v2.0.0 - manager.add_repository( - repo_name="Test Repo v2", - repo_description="Version 2", - repo_url=base_url, - repo_author="Author", - repo_version="2.0.0", - instructions=[], - namespace=manager.get_versioned_namespace(base_url, "v2.0.0"), - ) - - # List versions - versions = manager.list_repository_versions(base_url) - assert len(versions) == 2 - assert any("v1.0.0" in ref for ref, _ in versions) - assert any("v2.0.0" in ref for ref, _ in versions) diff --git a/tests/unit/cli/test_delete.py b/tests/unit/cli/test_delete.py deleted file mode 100644 index 5aeea07..0000000 --- a/tests/unit/cli/test_delete.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Unit tests for delete CLI command.""" - -from datetime import datetime -from unittest.mock import MagicMock, patch - -from devsync.cli.delete import delete_from_library -from devsync.core.models import ( - AIToolType, - InstallationRecord, - InstallationScope, - LibraryInstruction, - LibraryRepository, -) - - -class TestDeleteFromLibrary: - """Test delete_from_library function.""" - - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_nonexistent_repository(self, mock_library_class: MagicMock, mock_tracker_class: MagicMock) -> None: - """Test deleting non-existent repository.""" - mock_library = MagicMock() - mock_library.get_repository.return_value = None - mock_library_class.return_value = mock_library - - result = delete_from_library("nonexistent-namespace") - - assert result == 1 # Error code - mock_library.remove_repository.assert_not_called() - - @patch("devsync.cli.delete.Confirm") - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_with_force( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_confirm: MagicMock - ) -> None: - """Test deleting repository with force flag.""" - # Setup library - mock_library = MagicMock() - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - mock_library.get_repository.return_value = repo - mock_library.remove_repository.return_value = True - mock_library_class.return_value = mock_library - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker.list_installations.return_value = [] - mock_tracker_class.return_value = mock_tracker - - # Execute with force=True (should skip confirmation) - result = delete_from_library("test/repo", force=True) - - assert result == 0 # Success - mock_library.remove_repository.assert_called_once_with("test/repo") - mock_confirm.ask.assert_not_called() # Confirmation should be skipped - - @patch("devsync.cli.delete.Confirm") - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_with_confirmation_cancelled( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_confirm: MagicMock - ) -> None: - """Test deleting repository when user cancels confirmation.""" - # Setup library - mock_library = MagicMock() - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker.list_installations.return_value = [] - mock_tracker_class.return_value = mock_tracker - - # User cancels confirmation - mock_confirm.ask.return_value = False - - result = delete_from_library("test/repo", force=False) - - assert result == 0 # Success (cancelled, but not an error) - mock_library.remove_repository.assert_not_called() - - @patch("devsync.cli.delete.Confirm") - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_with_installed_instructions_warning( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_confirm: MagicMock - ) -> None: - """Test warning when deleting repository with installed instructions.""" - # Setup library - mock_library = MagicMock() - instructions = [ - LibraryInstruction( - id="test/inst1", - name="inst1", - description="Instruction 1", - repo_namespace="test/repo", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path="/path/to/inst1.md", - ) - ] - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=instructions, - ) - mock_library.get_repository.return_value = repo - mock_library.remove_repository.return_value = True - mock_library_class.return_value = mock_library - - # Setup tracker with installed instructions - mock_tracker = MagicMock() - installed_record = InstallationRecord( - instruction_name="inst1", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", - installed_path="/path/to/install/inst1.mdc", - installed_at=datetime.now(), - scope=InstallationScope.GLOBAL, - ) - mock_tracker.list_installations.return_value = [installed_record] - mock_tracker_class.return_value = mock_tracker - - # User confirms deletion - mock_confirm.ask.return_value = True - - result = delete_from_library("test/repo", force=False) - - assert result == 0 # Success - # Should have warned about installed instructions - mock_confirm.ask.assert_called_once() - - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_failure(self, mock_library_class: MagicMock, mock_tracker_class: MagicMock) -> None: - """Test when repository deletion fails.""" - # Setup library - mock_library = MagicMock() - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - mock_library.get_repository.return_value = repo - mock_library.remove_repository.return_value = False # Deletion fails - mock_library_class.return_value = mock_library - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker.list_installations.return_value = [] - mock_tracker_class.return_value = mock_tracker - - result = delete_from_library("test/repo", force=True) - - assert result == 1 # Error code - - @patch("devsync.cli.delete.Confirm") - @patch("devsync.cli.delete.InstallationTracker") - @patch("devsync.cli.delete.LibraryManager") - def test_delete_with_many_installed_instructions( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_confirm: MagicMock - ) -> None: - """Test warning when deleting repository with >5 installed instructions (shows '... and X more').""" - # Setup library - mock_library = MagicMock() - instructions = [ - LibraryInstruction( - id=f"test/inst{i}", - name=f"inst{i}", - description=f"Instruction {i}", - repo_namespace="test/repo", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path=f"/path/to/inst{i}.md", - ) - for i in range(7) # Create 7 instructions (>5) - ] - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=instructions, - ) - mock_library.get_repository.return_value = repo - mock_library.remove_repository.return_value = True - mock_library_class.return_value = mock_library - - # Setup tracker with 7 installed instructions - mock_tracker = MagicMock() - installed_records = [ - InstallationRecord( - instruction_name=f"inst{i}", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", - installed_path=f"/path/to/install/inst{i}.mdc", - installed_at=datetime.now(), - scope=InstallationScope.GLOBAL, - ) - for i in range(7) - ] - mock_tracker.list_installations.return_value = installed_records - mock_tracker_class.return_value = mock_tracker - - # User confirms deletion - mock_confirm.ask.return_value = True - - result = delete_from_library("test/repo", force=False) - - assert result == 0 # Success - # Should have shown "... and 2 more" message (line 53 coverage) - mock_confirm.ask.assert_called_once() diff --git a/tests/unit/cli/test_download.py b/tests/unit/cli/test_download.py deleted file mode 100644 index 15048f4..0000000 --- a/tests/unit/cli/test_download.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Unit tests for download CLI command.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from devsync.cli.download import download_instructions -from devsync.core.git_operations import RepositoryOperationError -from devsync.core.models import Instruction - - -class TestDownloadInstructions: - """Test download_instructions function.""" - - @patch("devsync.cli.download.GitOperations.is_local_path") - @patch("devsync.cli.download.GitOperations.detect_ref_type") - def test_download_remote_invalid_reference(self, mock_detect_ref: MagicMock, mock_is_local: MagicMock) -> None: - """Test downloading with invalid reference.""" - mock_is_local.return_value = False - mock_detect_ref.side_effect = RepositoryOperationError("invalid_reference", "Ref not found") - - result = download_instructions("https://github.com/test/repo", ref="invalid-tag") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.is_local_path") - @patch("devsync.cli.download.GitOperations.detect_ref_type") - def test_download_remote_network_error(self, mock_detect_ref: MagicMock, mock_is_local: MagicMock) -> None: - """Test downloading with network error.""" - mock_is_local.return_value = False - mock_detect_ref.side_effect = RepositoryOperationError("network_error", "Connection failed") - - result = download_instructions("https://github.com/test/repo", ref="v1.0.0") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.is_local_path") - @patch("devsync.cli.download.GitOperations.detect_ref_type") - def test_download_remote_generic_ref_error(self, mock_detect_ref: MagicMock, mock_is_local: MagicMock) -> None: - """Test downloading with generic ref validation error.""" - mock_is_local.return_value = False - mock_detect_ref.side_effect = RepositoryOperationError("unknown", "Unknown error") - - result = download_instructions("https://github.com/test/repo", ref="v1.0.0") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.is_local_path") - def test_download_local_with_ref(self, mock_is_local: MagicMock) -> None: - """Test downloading from local path with ref (should error).""" - mock_is_local.return_value = True - - result = download_instructions("/local/path", ref="v1.0.0") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.cleanup_repository") - @patch("devsync.cli.download.LibraryManager") - @patch("devsync.cli.download.RepositoryParser") - @patch("devsync.cli.download.GitOperations") - def test_download_local_success( - self, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_library_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test downloading from local path successfully.""" - repo_path = tmp_path / "local_repo" - repo_path.mkdir() - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = True - mock_git_ops_class.is_local_path.return_value = True - - # Setup repository parser - inst_file = repo_path / "test.md" - inst_file.write_text("# Test") - - instruction = Instruction(name="test", description="Test", content="# Test", file_path="test.md", tags=[]) - - mock_repo = MagicMock() - mock_repo.instructions = [instruction] - mock_repo.metadata = {"name": "Test Repo", "version": "1.0.0", "author": "Test", "description": "Test"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Setup library - library_dir = tmp_path / "library" - library_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repo_namespace.return_value = "test/repo" - mock_library.get_repository.return_value = None - mock_library.add_repository.return_value = MagicMock(alias="test-repo", namespace="test/repo") - mock_library_class.return_value = mock_library - - result = download_instructions(str(repo_path)) - - assert result == 0 # Success - mock_library.add_repository.assert_called_once() - - @patch("devsync.cli.download.shutil.rmtree") - @patch("tempfile.mkdtemp") - @patch("devsync.cli.download.GitOperations") - def test_download_remote_clone_failure( - self, mock_git_ops_class: MagicMock, mock_mkdtemp: MagicMock, mock_rmtree: MagicMock, tmp_path: Path - ) -> None: - """Test downloading when clone fails.""" - temp_dir = tmp_path / "temp" - temp_dir.mkdir() - - mock_mkdtemp.return_value = str(temp_dir) - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops_class.is_local_path.return_value = False - mock_git_ops_class.detect_ref_type.return_value = ("v1.0.0", MagicMock(value="tag")) - mock_git_ops_class.clone_at_ref.side_effect = RepositoryOperationError("clone_failed", "Clone failed") - - result = download_instructions("https://github.com/test/repo", ref="v1.0.0") - - assert result == 1 # Error code - mock_rmtree.assert_called_once() - - @patch("devsync.cli.download.LibraryManager") - @patch("devsync.cli.download.RepositoryParser") - @patch("devsync.cli.download.GitOperations.is_local_path") - def test_download_already_exists_no_force( - self, mock_is_local: MagicMock, mock_parser_class: MagicMock, mock_library_class: MagicMock, tmp_path: Path - ) -> None: - """Test downloading when repository already exists without force.""" - mock_is_local.return_value = True - - # Setup repository parser - mock_repo = MagicMock() - mock_repo.instructions = [] - mock_repo.metadata = {"name": "Test Repo"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Setup library with existing repository - existing_repo = MagicMock(alias="test-repo") - mock_library = MagicMock() - mock_library.get_repo_namespace.return_value = "test/repo" - mock_library.get_repository.return_value = existing_repo - mock_library_class.return_value = mock_library - - result = download_instructions(str(tmp_path), force=False) - - assert result == 1 # Error code - mock_library.add_repository.assert_not_called() - - @patch("devsync.cli.download.GitOperations.cleanup_repository") - @patch("devsync.cli.download.LibraryManager") - @patch("devsync.cli.download.RepositoryParser") - @patch("devsync.cli.download.GitOperations.is_local_path") - def test_download_file_not_found_warning( - self, - mock_is_local: MagicMock, - mock_parser_class: MagicMock, - mock_library_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test downloading when instruction file doesn't exist (warning).""" - mock_is_local.return_value = True - - # Setup repository parser with instruction pointing to non-existent file - instruction = Instruction( - name="test", description="Test", content="# Test", file_path="nonexistent.md", tags=[] - ) - - mock_repo = MagicMock() - mock_repo.instructions = [instruction] - mock_repo.metadata = {"name": "Test Repo", "version": "1.0.0"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Setup library - library_dir = tmp_path / "library" - library_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repo_namespace.return_value = "test/repo" - mock_library.get_repository.return_value = None - mock_library.add_repository.return_value = MagicMock(alias="test-repo") - mock_library_class.return_value = mock_library - - result = download_instructions(str(tmp_path)) - - # Should succeed but with warning (no instructions added) - assert result == 0 # Success - # Verify add_repository was called with empty instructions list - call_args = mock_library.add_repository.call_args - assert call_args[1]["instructions"] == [] - - @patch("devsync.cli.download.GitOperations.cleanup_repository") - @patch("devsync.cli.download.shutil.copytree") - @patch("devsync.cli.download.shutil.rmtree") - @patch("devsync.cli.download.LibraryManager") - @patch("devsync.cli.download.RepositoryParser") - @patch("tempfile.mkdtemp") - @patch("devsync.cli.download.GitOperations") - def test_download_remote_with_git_dir( - self, - mock_git_ops_class: MagicMock, - mock_mkdtemp: MagicMock, - mock_parser_class: MagicMock, - mock_library_class: MagicMock, - mock_rmtree: MagicMock, - mock_copytree: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test downloading remote repository preserves .git directory.""" - # Setup temp directory - temp_dir = tmp_path / "temp" - temp_dir.mkdir() - git_dir = temp_dir / ".git" - git_dir.mkdir() - - mock_mkdtemp.return_value = str(temp_dir) - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops_class.is_local_path.return_value = False - mock_git_ops_class.detect_ref_type.return_value = ("v1.0.0", MagicMock(value="tag")) - mock_git_ops_class.clone_at_ref.return_value = None - - # Setup instruction file - inst_file = temp_dir / "test.md" - inst_file.write_text("# Test") - - # Setup repository parser - instruction = Instruction(name="test", description="Test", content="# Test", file_path="test.md", tags=[]) - - mock_repo = MagicMock() - mock_repo.instructions = [instruction] - mock_repo.metadata = {"name": "Test Repo", "version": "1.0.0"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Setup library - library_dir = tmp_path / "library" - library_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_versioned_namespace.return_value = "test/repo@v1.0.0" - mock_library.get_repository.return_value = None - mock_library.add_repository.return_value = MagicMock(alias="test-repo", namespace="test/repo@v1.0.0") - mock_library_class.return_value = mock_library - - result = download_instructions("https://github.com/test/repo", ref="v1.0.0") - - assert result == 0 # Success - # Verify .git directory was copied - mock_copytree.assert_called() - - @patch("devsync.cli.download.GitOperations.is_local_path") - @patch("devsync.cli.download.RepositoryParser") - def test_download_parse_error(self, mock_parser_class: MagicMock, mock_is_local: MagicMock) -> None: - """Test downloading when parsing fails.""" - mock_is_local.return_value = True - - # Parser raises FileNotFoundError - mock_parser = MagicMock() - mock_parser.parse.side_effect = FileNotFoundError("templatekit.yaml not found") - mock_parser_class.return_value = mock_parser - - result = download_instructions("/local/path") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.is_local_path") - @patch("devsync.cli.download.RepositoryParser") - def test_download_generic_exception(self, mock_parser_class: MagicMock, mock_is_local: MagicMock) -> None: - """Test downloading with generic exception.""" - mock_is_local.return_value = True - - # Parser raises generic exception - mock_parser = MagicMock() - mock_parser.parse.side_effect = RuntimeError("Unexpected error") - mock_parser_class.return_value = mock_parser - - result = download_instructions("/local/path") - - assert result == 1 # Error code - - @patch("devsync.cli.download.GitOperations.cleanup_repository") - @patch("devsync.cli.download.LibraryManager") - @patch("devsync.cli.download.RepositoryParser") - @patch("tempfile.mkdtemp") - @patch("devsync.cli.download.GitOperations") - def test_download_remote_with_branch_ref( - self, - mock_git_ops_class: MagicMock, - mock_mkdtemp: MagicMock, - mock_parser_class: MagicMock, - mock_library_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test downloading from remote repository with branch reference.""" - # Setup temp directory - temp_dir = tmp_path / "temp" - temp_dir.mkdir() - - mock_mkdtemp.return_value = str(temp_dir) - - # Setup Git operations - ref_type_mock = MagicMock() - ref_type_mock.value = "branch" - - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops_class.is_local_path.return_value = False - mock_git_ops_class.detect_ref_type.return_value = ("main", ref_type_mock) - mock_git_ops_class.clone_at_ref.return_value = None - - # Setup instruction file - inst_file = temp_dir / "test.md" - inst_file.write_text("# Test") - - # Setup repository parser - instruction = Instruction(name="test", description="Test", content="# Test", file_path="test.md", tags=[]) - - mock_repo = MagicMock() - mock_repo.instructions = [instruction] - mock_repo.metadata = {"name": "Test Repo", "version": "1.0.0"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Setup library - library_dir = tmp_path / "library" - library_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_versioned_namespace.return_value = "test/repo@main" - mock_library.get_repository.return_value = None - mock_library.add_repository.return_value = MagicMock(alias="test-repo", namespace="test/repo@main") - mock_library_class.return_value = mock_library - - result = download_instructions("https://github.com/test/repo", ref="main") - - assert result == 0 # Success - mock_cleanup.assert_called_once() diff --git a/tests/unit/cli/test_install.py b/tests/unit/cli/test_install.py deleted file mode 100644 index 295ab39..0000000 --- a/tests/unit/cli/test_install.py +++ /dev/null @@ -1,551 +0,0 @@ -"""Unit tests for install CLI command.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -from devsync.cli.install import _get_ai_tool, install_instruction -from devsync.core.models import Instruction - - -class TestInstallInstruction: - """Test install_instruction function.""" - - @patch("devsync.cli.install.is_valid_git_url") - def test_install_invalid_git_url(self, mock_valid: MagicMock) -> None: - """Test installing with invalid Git URL.""" - mock_valid.return_value = False - - result = install_instruction("test", "invalid-url") - - assert result == 1 # Error code - - @patch("devsync.cli.install.is_valid_git_url") - def test_install_invalid_conflict_strategy(self, mock_valid: MagicMock) -> None: - """Test installing with invalid conflict strategy.""" - mock_valid.return_value = True - - result = install_instruction("test", "https://github.com/test/repo", conflict_strategy="invalid") - - assert result == 1 # Error code - - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_no_project_root(self, mock_valid: MagicMock, mock_find_root: MagicMock) -> None: - """Test installing when project root cannot be detected.""" - mock_valid.return_value = True - mock_find_root.return_value = None - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.install.GitOperations.is_git_installed") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_git_not_installed( - self, mock_valid: MagicMock, mock_find_root: MagicMock, mock_git_installed: MagicMock - ) -> None: - """Test installing when Git is not installed.""" - mock_valid.return_value = True - mock_find_root.return_value = Path("/project") - mock_git_installed.return_value = False - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.GitOperations.is_git_installed") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_no_ai_tool( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_git_installed: MagicMock, - mock_get_tool: MagicMock, - ) -> None: - """Test installing when AI tool cannot be determined.""" - mock_valid.return_value = True - mock_find_root.return_value = Path("/project") - mock_git_installed.return_value = True - mock_get_tool.return_value = None - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.GitOperations.is_git_installed") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_ai_tool_validation_error( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_git_installed: MagicMock, - mock_get_tool: MagicMock, - ) -> None: - """Test installing when AI tool validation fails.""" - mock_valid.return_value = True - mock_find_root.return_value = Path("/project") - mock_git_installed.return_value = True - - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = "Tool not configured properly" - mock_get_tool.return_value = mock_tool - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_local_directory( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing from local directory.""" - mock_valid.return_value = True - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.tool_name = "Cursor" - mock_tool.validate_installation.return_value = None - mock_tool.get_instruction_path.return_value = project_root / ".cursor" / "rules" / "test.mdc" - mock_tool.tool_type = "cursor" - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_git_installed.return_value = True - mock_git_ops.is_local_path.return_value = True - mock_git_ops.clone_repository.return_value = Path("/local/path") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - mock_instruction = Instruction( - name="test", description="Test", content="# Test content", file_path="test.md", checksum="abc123" - ) - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="file:///local/path") - mock_parser.get_instruction_by_name.return_value = mock_instruction - mock_parser_class.return_value = mock_parser - - # Run installation - result = install_instruction("test", "/local/path") - - assert result == 0 # Success - mock_cleanup.assert_called_once_with(Path("/local/path"), is_temp=False) - - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_clone_failure( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing when repository clone fails.""" - mock_valid.return_value = True - mock_find_root.return_value = tmp_path - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - mock_get_tool.return_value = mock_tool - - # Setup Git operations to fail - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.side_effect = RuntimeError("Clone failed") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_instruction_not_found( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing when instruction is not found in repository.""" - mock_valid.return_value = True - mock_find_root.return_value = tmp_path - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - instruction not found - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="https://github.com/test/repo") - mock_parser.get_instruction_by_name.return_value = None - mock_parser_class.return_value = mock_parser - - result = install_instruction("nonexistent", "https://github.com/test/repo") - - assert result == 1 # Error code - mock_cleanup.assert_called_once() - - @patch("devsync.cli.install.InstallationTracker") - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_skip_existing( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - mock_tracker_class: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing with SKIP strategy when instruction exists.""" - mock_valid.return_value = True - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Create existing file - existing_file = project_root / ".cursor" / "rules" / "test.mdc" - existing_file.parent.mkdir(parents=True) - existing_file.write_text("existing") - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - mock_tool.get_instruction_path.return_value = existing_file - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - mock_instruction = Instruction( - name="test", description="Test", content="# Test content", file_path="test.md", checksum="abc123" - ) - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="https://github.com/test/repo") - mock_parser.get_instruction_by_name.return_value = mock_instruction - mock_parser_class.return_value = mock_parser - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = install_instruction("test", "https://github.com/test/repo", conflict_strategy="skip") - - assert result == 0 # Success (but skipped) - # File content should remain unchanged - assert existing_file.read_text() == "existing" - mock_tracker.add_installation.assert_not_called() - - @patch("devsync.cli.install.ChecksumValidator") - @patch("devsync.cli.install.InstallationTracker") - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_rename_existing( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - mock_tracker_class: MagicMock, - mock_checksum_class: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing with RENAME strategy when instruction exists.""" - mock_valid.return_value = True - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Create existing file - existing_file = project_root / ".cursor" / "rules" / "test.mdc" - existing_file.parent.mkdir(parents=True) - existing_file.write_text("existing") - - # Setup AI tool - use real Path objects for proper file operations - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - mock_tool.tool_type = "cursor" - # First call returns existing file, second call will be used after rename - mock_tool.get_instruction_path.return_value = existing_file - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - mock_instruction = Instruction( - name="test", description="Test", content="# Test content", file_path="test.md", checksum="abc123" - ) - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="https://github.com/test/repo") - mock_parser.get_instruction_by_name.return_value = mock_instruction - mock_parser_class.return_value = mock_parser - - # Setup checksum validator to pass - mock_checksum = MagicMock() - mock_checksum.validate.return_value = None # No exception = valid - mock_checksum_class.return_value = mock_checksum - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = install_instruction("test", "https://github.com/test/repo", conflict_strategy="rename") - - assert result == 0 # Success - # With real ConflictResolver, a renamed file should be created (test-1.mdc) - # Check that tracker was called (file was installed somewhere) - mock_tracker.add_installation.assert_called_once() - - @patch("devsync.cli.install.ChecksumValidator") - @patch("devsync.cli.install.InstallationTracker") - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_checksum_validation_failure( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - mock_tracker_class: MagicMock, - mock_checksum_class: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing when checksum validation fails.""" - mock_valid.return_value = True - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - target_file = project_root / ".cursor" / "rules" / "test.mdc" - mock_tool.get_instruction_path.return_value = target_file - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - mock_instruction = Instruction( - name="test", description="Test", content="# Test content", file_path="test.md", checksum="abc123" - ) - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="https://github.com/test/repo") - mock_parser.get_instruction_by_name.return_value = mock_instruction - mock_parser_class.return_value = mock_parser - - # Setup checksum validator to fail - mock_checksum = MagicMock() - mock_checksum.validate.side_effect = ValueError("Checksum mismatch") - mock_checksum_class.return_value = mock_checksum - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = install_instruction("test", "https://github.com/test/repo") - - assert result == 0 # Success (but no instructions installed) - # File should not be created due to checksum failure - assert not target_file.exists() - mock_tracker.add_installation.assert_not_called() - - @patch("devsync.cli.install.ChecksumValidator") - @patch("devsync.cli.install.InstallationTracker") - @patch("devsync.cli.install.GitOperations.cleanup_repository") - @patch("devsync.cli.install.RepositoryParser") - @patch("devsync.cli.install.GitOperations") - @patch("devsync.cli.install._get_ai_tool") - @patch("devsync.cli.install.find_project_root") - @patch("devsync.cli.install.is_valid_git_url") - def test_install_bundle( - self, - mock_valid: MagicMock, - mock_find_root: MagicMock, - mock_get_tool: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - mock_tracker_class: MagicMock, - mock_checksum_class: MagicMock, - tmp_path: Path, - ) -> None: - """Test installing a bundle of instructions.""" - mock_valid.return_value = True - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Setup AI tool with real path generation - mock_tool = MagicMock() - mock_tool.validate_installation.return_value = None - mock_tool.tool_type = "cursor" - - def get_path(name, scope, root): - return root / ".cursor" / "rules" / f"{name}.mdc" - - mock_tool.get_instruction_path.side_effect = get_path - mock_get_tool.return_value = mock_tool - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser with bundle - instructions = [ - Instruction(name="test1", description="Test 1", content="# Test 1", file_path="test1.md", checksum="abc"), - Instruction(name="test2", description="Test 2", content="# Test 2", file_path="test2.md", checksum="def"), - ] - mock_parser = MagicMock() - mock_parser.parse.return_value = MagicMock(url="https://github.com/test/repo") - mock_parser.get_instructions_for_bundle.return_value = instructions - mock_parser_class.return_value = mock_parser - - # Setup checksum validator to pass - mock_checksum = MagicMock() - mock_checksum.validate.return_value = None # No exception = valid - mock_checksum_class.return_value = mock_checksum - - # Setup tracker - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = install_instruction("test-bundle", "https://github.com/test/repo", bundle=True) - - assert result == 0 # Success - # Both instructions should be installed (verify via tracker calls) - assert mock_tracker.add_installation.call_count == 2 - # Files should exist in the project - assert (project_root / ".cursor" / "rules" / "test1.mdc").exists() - assert (project_root / ".cursor" / "rules" / "test2.mdc").exists() - # Verify content - assert (project_root / ".cursor" / "rules" / "test1.mdc").read_text() == "# Test 1" - assert (project_root / ".cursor" / "rules" / "test2.mdc").read_text() == "# Test 2" - - -class TestGetAITool: - """Test _get_ai_tool helper function.""" - - @patch("devsync.cli.install.get_detector") - def test_get_ai_tool_by_name(self, mock_get_detector: MagicMock) -> None: - """Test getting AI tool by name.""" - mock_tool = MagicMock() - mock_tool.is_installed.return_value = True - - mock_detector = MagicMock() - mock_detector.get_tool_by_name.return_value = mock_tool - mock_get_detector.return_value = mock_detector - - result = _get_ai_tool("cursor") - - assert result == mock_tool - mock_detector.get_tool_by_name.assert_called_once_with("cursor") - - @patch("devsync.cli.install.get_detector") - def test_get_ai_tool_not_installed(self, mock_get_detector: MagicMock) -> None: - """Test getting AI tool that is not installed.""" - mock_tool = MagicMock() - mock_tool.is_installed.return_value = False - mock_tool.tool_name = "Cursor" - - mock_detector = MagicMock() - mock_detector.get_tool_by_name.return_value = mock_tool - mock_get_detector.return_value = mock_detector - - result = _get_ai_tool("cursor") - - assert result is None - - @patch("devsync.cli.install.get_detector") - def test_get_ai_tool_auto_detect(self, mock_get_detector: MagicMock) -> None: - """Test auto-detecting primary AI tool.""" - mock_tool = MagicMock() - - mock_detector = MagicMock() - mock_detector.get_primary_tool.return_value = mock_tool - mock_get_detector.return_value = mock_detector - - result = _get_ai_tool(None) - - assert result == mock_tool - mock_detector.get_primary_tool.assert_called_once() diff --git a/tests/unit/cli/test_list.py b/tests/unit/cli/test_list.py deleted file mode 100644 index 3d5cbfe..0000000 --- a/tests/unit/cli/test_list.py +++ /dev/null @@ -1,609 +0,0 @@ -"""Unit tests for list CLI command.""" - -from datetime import datetime -from pathlib import Path -from unittest.mock import MagicMock, patch - -from devsync.cli.list import list_available, list_installed, list_library -from devsync.core.models import ( - AIToolType, - InstallationRecord, - InstallationScope, - Instruction, - InstructionBundle, - LibraryInstruction, - LibraryRepository, -) - - -class TestListAvailable: - """Test list_available function.""" - - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_invalid_url(self, mock_valid: MagicMock) -> None: - """Test listing available with invalid Git URL.""" - mock_valid.return_value = False - - result = list_available("invalid-url") - - assert result == 1 # Error code - - @patch("devsync.cli.list.GitOperations.is_git_installed") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_git_not_installed(self, mock_valid: MagicMock, mock_git_installed: MagicMock) -> None: - """Test listing when Git is not installed.""" - mock_valid.return_value = True - mock_git_installed.return_value = False - - result = list_available("https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_local_path_error( - self, mock_valid: MagicMock, mock_git_ops_class: MagicMock, mock_cleanup: MagicMock - ) -> None: - """Test listing when local path access fails.""" - mock_valid.return_value = True - - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = True - mock_git_ops.clone_repository.side_effect = RuntimeError("Access denied") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - result = list_available("/local/path") - - assert result == 1 # Error code - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_clone_failure( - self, mock_valid: MagicMock, mock_git_ops_class: MagicMock, mock_cleanup: MagicMock - ) -> None: - """Test listing when repository clone fails.""" - mock_valid.return_value = True - - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.side_effect = RuntimeError("Clone failed") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - result = list_available("https://github.com/test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_success( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test listing available instructions successfully.""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser with instructions and bundles - instructions = [ - Instruction(name="test1", description="Test 1", content="# Test 1", file_path="test1.md", tags=["python"]) - ] - bundles = [InstructionBundle(name="bundle1", description="Bundle 1", instructions=["test1"], tags=["python"])] - - mock_repo = MagicMock() - mock_repo.instructions = instructions - mock_repo.bundles = bundles - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - result = list_available("https://github.com/test/repo") - - assert result == 0 # Success - mock_cleanup.assert_called_once() - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_with_tag_filter( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test listing with tag filter.""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser with mixed tags - instructions = [ - Instruction(name="test1", description="Test 1", content="# Test 1", file_path="test1.md", tags=["python"]), - Instruction( - name="test2", description="Test 2", content="# Test 2", file_path="test2.md", tags=["javascript"] - ), - ] - - mock_repo = MagicMock() - mock_repo.instructions = instructions - mock_repo.bundles = [] - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - result = list_available("https://github.com/test/repo", tag="python") - - assert result == 0 # Success - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_bundles_only( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test listing bundles only.""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser - bundles = [InstructionBundle(name="bundle1", description="Bundle 1", instructions=["test1"], tags=[])] - - mock_repo = MagicMock() - mock_repo.instructions = [] - mock_repo.bundles = bundles - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - result = list_available("https://github.com/test/repo", bundles_only=True) - - assert result == 0 # Success - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_empty_result( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test listing with no results.""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository parser with empty content - mock_repo = MagicMock() - mock_repo.instructions = [] - mock_repo.bundles = [] - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - result = list_available("https://github.com/test/repo") - - assert result == 0 # Success (empty is not an error) - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_instructions_only_with_only_bundles( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test instructions_only filter when repo only has bundles (line 98).""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository with only bundles (no instructions) - bundles = [InstructionBundle(name="bundle1", description="Bundle 1", instructions=["test1"], tags=[])] - - mock_repo = MagicMock() - mock_repo.instructions = [] - mock_repo.bundles = bundles - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - result = list_available("https://github.com/test/repo", instructions_only=True) - - assert result == 0 # Success (bundles filtered out, empty result) - - @patch("devsync.cli.list.GitOperations.cleanup_repository") - @patch("devsync.cli.list.RepositoryParser") - @patch("devsync.cli.list.GitOperations") - @patch("devsync.cli.list.is_valid_git_url") - def test_list_available_with_tag_no_results( - self, - mock_valid: MagicMock, - mock_git_ops_class: MagicMock, - mock_parser_class: MagicMock, - mock_cleanup: MagicMock, - ) -> None: - """Test filtering by tag with no matching results (line 103).""" - mock_valid.return_value = True - - # Setup Git operations - mock_git_ops = MagicMock() - mock_git_ops.is_local_path.return_value = False - mock_git_ops.clone_repository.return_value = Path("/tmp/repo") - mock_git_ops_class.return_value = mock_git_ops - mock_git_ops_class.is_git_installed.return_value = True - - # Setup repository with instructions that don't match tag filter - instructions = [ - Instruction( - name="inst1", description="Instruction 1", content="Some content", file_path="inst1.md", tags=["python"] - ) - ] - - mock_repo = MagicMock() - mock_repo.instructions = instructions - mock_repo.bundles = [] - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo - mock_parser_class.return_value = mock_parser - - # Filter by tag that doesn't exist - result = list_available("https://github.com/test/repo", tag="javascript") - - assert result == 0 # Success (no matches for tag) - - -class TestListInstalled: - """Test list_installed function.""" - - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_no_results(self, mock_tracker_class: MagicMock, mock_find_root: MagicMock) -> None: - """Test listing installed with no results.""" - mock_find_root.return_value = Path("/project") - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = [] - mock_tracker_class.return_value = mock_tracker - - result = list_installed() - - assert result == 0 # Success (empty is not an error) - - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_invalid_tool(self, mock_tracker_class: MagicMock, mock_find_root: MagicMock) -> None: - """Test listing with invalid tool name.""" - mock_find_root.return_value = Path("/project") - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = list_installed(tool="invalid-tool") - - assert result == 1 # Error code - - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_with_tool_filter(self, mock_tracker_class: MagicMock, mock_find_root: MagicMock) -> None: - """Test listing with tool filter.""" - mock_find_root.return_value = Path("/project") - - # Setup tracker with cursor installation - records = [ - InstallationRecord( - instruction_name="test1", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", - installed_path="/path/to/test1.mdc", - installed_at=datetime.now(), - scope=InstallationScope.PROJECT, - ) - ] - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = records - mock_tracker_class.return_value = mock_tracker - - result = list_installed(tool="cursor") - - assert result == 0 # Success - mock_tracker.get_installed_instructions.assert_called_once() - - @patch("devsync.cli.list.normalize_repo_url") - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_with_repo_filter( - self, mock_tracker_class: MagicMock, mock_find_root: MagicMock, mock_normalize: MagicMock - ) -> None: - """Test listing with repository filter.""" - mock_find_root.return_value = Path("/project") - mock_normalize.return_value = "https://github.com/test/repo" - - # Setup tracker with installation from specific repo - records = [ - InstallationRecord( - instruction_name="test1", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", - installed_path="/path/to/test1.mdc", - installed_at=datetime.now(), - scope=InstallationScope.PROJECT, - ) - ] - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = records - mock_tracker_class.return_value = mock_tracker - - result = list_installed(repo="https://github.com/test/repo") - - assert result == 0 # Success - - @patch("devsync.cli.list.normalize_repo_url") - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_no_results_for_tool_and_repo( - self, mock_tracker_class: MagicMock, mock_find_root: MagicMock, mock_normalize: MagicMock - ) -> None: - """Test no results for specific tool and repo filter (line 164).""" - mock_find_root.return_value = Path("/project") - mock_normalize.return_value = "https://github.com/test/repo" - - # Setup tracker with empty results - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = [] - mock_tracker_class.return_value = mock_tracker - - result = list_installed(tool="cursor", repo="https://github.com/test/repo") - - assert result == 0 # Success (no matches) - - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_no_results_for_tool_only( - self, mock_tracker_class: MagicMock, mock_find_root: MagicMock - ) -> None: - """Test no results for specific tool filter (line 166).""" - mock_find_root.return_value = Path("/project") - - # Setup tracker to return empty list when queried for claude - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = [] # No results for claude - mock_tracker_class.return_value = mock_tracker - - # Filter for tool with no installations - result = list_installed(tool="claude") - - assert result == 0 # Success (no matches for claude) - - @patch("devsync.cli.list.normalize_repo_url") - @patch("devsync.cli.list.find_project_root") - @patch("devsync.cli.list.InstallationTracker") - def test_list_installed_no_results_for_repo_only( - self, mock_tracker_class: MagicMock, mock_find_root: MagicMock, mock_normalize: MagicMock - ) -> None: - """Test no results for specific repo filter (line 168).""" - mock_find_root.return_value = Path("/project") - - # Setup normalize_repo_url to return different values for different repos - def normalize_side_effect(url): - return url # Just return as-is for testing - - mock_normalize.side_effect = normalize_side_effect - - # Setup tracker with installation from different repo - records = [ - InstallationRecord( - instruction_name="test1", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", # Different repo - installed_path="/path/to/test1.mdc", - installed_at=datetime.now(), - scope=InstallationScope.PROJECT, - ) - ] - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = records - mock_tracker_class.return_value = mock_tracker - - # Filter for different repo (will filter out all records) - result = list_installed(repo="https://github.com/other/repo") - - assert result == 0 # Success (no matches for that repo) - - -class TestListLibrary: - """Test list_library function.""" - - @patch("devsync.cli.list.LibraryManager") - def test_list_library_empty(self, mock_library_class: MagicMock) -> None: - """Test listing empty library.""" - mock_library = MagicMock() - mock_library.list_repositories.return_value = [] - mock_library_class.return_value = mock_library - - result = list_library() - - assert result == 0 # Success - - @patch("devsync.cli.list.LibraryManager") - def test_list_library_with_sources(self, mock_library_class: MagicMock) -> None: - """Test listing library sources.""" - # Create library repositories - repos = [ - LibraryRepository( - namespace="test/repo1", - name="Test Repo 1", - description="Test", - url="https://github.com/test/repo1", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="repo1", - instructions=[], - ), - LibraryRepository( - namespace="test/repo2", - name="Test Repo 2", - description="Test", - url="https://github.com/test/repo2", - author="Author", - version="2.0.0", - downloaded_at=datetime.now(), - alias="repo2", - instructions=[], - ), - ] - - mock_library = MagicMock() - mock_library.list_repositories.return_value = repos - mock_library_class.return_value = mock_library - - result = list_library() - - assert result == 0 # Success - - @patch("devsync.cli.list.LibraryManager") - def test_list_library_with_filter_match(self, mock_library_class: MagicMock) -> None: - """Test listing library with matching filter.""" - repos = [ - LibraryRepository( - namespace="test/repo1", - name="Test Repo 1", - description="Test", - url="https://github.com/test/repo1", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="repo1", - instructions=[], - ) - ] - - mock_library = MagicMock() - mock_library.list_repositories.return_value = repos - mock_library_class.return_value = mock_library - - result = list_library(repo_filter="repo1") - - assert result == 0 # Success - - @patch("devsync.cli.list.LibraryManager") - def test_list_library_with_filter_no_match(self, mock_library_class: MagicMock) -> None: - """Test listing library with non-matching filter.""" - repos = [ - LibraryRepository( - namespace="test/repo1", - name="Test Repo 1", - description="Test", - url="https://github.com/test/repo1", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="repo1", - instructions=[], - ) - ] - - mock_library = MagicMock() - mock_library.list_repositories.return_value = repos - mock_library_class.return_value = mock_library - - result = list_library(repo_filter="nonexistent") - - assert result == 1 # Error - no match - - @patch("devsync.cli.list.LibraryManager") - def test_list_library_with_instructions(self, mock_library_class: MagicMock) -> None: - """Test listing library with instructions view.""" - instructions = [ - LibraryInstruction( - id="test/repo1/inst1", - name="inst1", - description="Instruction 1", - repo_namespace="test/repo1", - repo_url="https://github.com/test/repo1", - repo_name="Test Repo 1", - author="Author", - version="1.0.0", - file_path="/path/to/inst1.md", - tags=["python", "testing", "debug", "extra"], - ) - ] - - repos = [ - LibraryRepository( - namespace="test/repo1", - name="Test Repo 1", - description="Test", - url="https://github.com/test/repo1", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="repo1", - instructions=instructions, - ) - ] - - mock_library = MagicMock() - mock_library.list_repositories.return_value = repos - mock_library_class.return_value = mock_library - - result = list_library(show_instructions=True) - - assert result == 0 # Success diff --git a/tests/unit/cli/test_package.py b/tests/unit/cli/test_package.py deleted file mode 100644 index 66eaff1..0000000 --- a/tests/unit/cli/test_package.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Unit tests for package CLI commands.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest -from click.exceptions import Exit - -from devsync.cli.package import ( - _display_installation_summary, - install_package_command, - list_packages_command, - uninstall_package_command, -) -from devsync.cli.package_install import InstallationResult -from devsync.core.models import ( - ComponentType, - InstallationStatus, -) - - -class TestInstallPackageCommand: - """Test install_package_command function.""" - - def test_install_invalid_ide(self) -> None: - """Test installing with invalid IDE name.""" - with pytest.raises(Exit) as exc_info: - install_package_command(package_path="/path/to/package", target_ide="invalid-ide") - - assert exc_info.value.exit_code == 1 - - def test_install_invalid_conflict_strategy(self) -> None: - """Test installing with invalid conflict resolution strategy.""" - with pytest.raises(Exit) as exc_info: - install_package_command(package_path="/path/to/package", target_ide="claude", conflict="invalid") - - assert exc_info.value.exit_code == 1 - - def test_install_project_not_found(self) -> None: - """Test installing when specified project directory doesn't exist.""" - with pytest.raises(Exit) as exc_info: - install_package_command( - package_path="/path/to/package", target_ide="claude", project="/nonexistent/project" - ) - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.package.find_project_root") - def test_install_no_project_root(self, mock_find_root: MagicMock) -> None: - """Test installing when project root cannot be found.""" - mock_find_root.return_value = None - - with pytest.raises(Exit) as exc_info: - install_package_command(package_path="/path/to/package", target_ide="claude") - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.package.find_project_root") - def test_install_package_not_found(self, mock_find_root: MagicMock) -> None: - """Test installing when package directory doesn't exist.""" - mock_find_root.return_value = Path("/project") - - with pytest.raises(Exit) as exc_info: - install_package_command(package_path="/nonexistent/package", target_ide="claude") - - assert exc_info.value.exit_code == 1 - - # Note: Success path tests removed because Typer commands with Option decorators - # cannot be called directly - they need CliRunner. Error paths test the critical logic. - - -class TestDisplayInstallationSummary: - """Test _display_installation_summary helper function.""" - - def test_display_complete_success(self) -> None: - """Test displaying complete installation summary.""" - result = InstallationResult( - success=True, - status=InstallationStatus.COMPLETE, - package_name="test-package", - version="1.0.0", - installed_count=5, - skipped_count=0, - failed_count=0, - components_installed={ComponentType.INSTRUCTION: 5}, - is_reinstall=False, - ) - - # Should not raise exception - _display_installation_summary(result, quiet=False) - - def test_display_partial_success(self) -> None: - """Test displaying partial installation summary.""" - result = InstallationResult( - success=True, - status=InstallationStatus.PARTIAL, - package_name="test-package", - version="1.0.0", - installed_count=3, - skipped_count=2, - failed_count=0, - components_installed={ComponentType.INSTRUCTION: 3}, - is_reinstall=False, - ) - - _display_installation_summary(result, quiet=False) - - def test_display_failed_installation(self) -> None: - """Test displaying failed installation summary.""" - result = InstallationResult( - success=False, - status=InstallationStatus.FAILED, - package_name="test-package", - version="1.0.0", - installed_count=0, - skipped_count=0, - failed_count=5, - components_installed={}, - is_reinstall=False, - error_message="Installation failed due to error", - ) - - _display_installation_summary(result, quiet=False) - - def test_display_with_reinstall(self) -> None: - """Test displaying reinstall summary.""" - result = InstallationResult( - success=True, - status=InstallationStatus.COMPLETE, - package_name="test-package", - version="1.0.0", - installed_count=4, - skipped_count=0, - failed_count=0, - components_installed={ComponentType.INSTRUCTION: 4}, - is_reinstall=True, - ) - - _display_installation_summary(result, quiet=False) - - def test_display_with_skipped_and_failed(self) -> None: - """Test displaying summary with skipped and failed counts.""" - result = InstallationResult( - success=True, - status=InstallationStatus.PARTIAL, - package_name="test-package", - version="1.0.0", - installed_count=3, - skipped_count=2, - failed_count=1, - components_installed={ComponentType.INSTRUCTION: 3}, - is_reinstall=False, - ) - - _display_installation_summary(result, quiet=False) - - def test_display_quiet_mode(self) -> None: - """Test displaying summary in quiet mode.""" - result = InstallationResult( - success=True, - status=InstallationStatus.COMPLETE, - package_name="test-package", - version="1.0.0", - installed_count=5, - skipped_count=0, - failed_count=0, - components_installed={ComponentType.INSTRUCTION: 5}, - is_reinstall=False, - ) - - _display_installation_summary(result, quiet=True) - - -class TestListPackagesCommand: - """Test list_packages_command function.""" - - def test_list_project_not_found(self) -> None: - """Test listing when specified project directory doesn't exist.""" - with pytest.raises(Exit) as exc_info: - list_packages_command(project="/nonexistent/project") - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.package.find_project_root") - def test_list_no_project_root(self, mock_find_root: MagicMock) -> None: - """Test listing when project root cannot be found.""" - mock_find_root.return_value = None - - with pytest.raises(Exit) as exc_info: - list_packages_command() - - assert exc_info.value.exit_code == 1 - - # Note: Success path tests removed - Typer commands with Option decorators - # cannot be called directly - they need CliRunner. Error paths test the critical logic. - - -class TestUninstallPackageCommand: - """Test uninstall_package_command function.""" - - def test_uninstall_project_not_found(self) -> None: - """Test uninstalling when specified project directory doesn't exist.""" - with pytest.raises(Exit) as exc_info: - uninstall_package_command(package_name="test-package", project="/nonexistent/project") - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.package.find_project_root") - def test_uninstall_no_project_root(self, mock_find_root: MagicMock) -> None: - """Test uninstalling when project root cannot be found.""" - mock_find_root.return_value = None - - with pytest.raises(Exit) as exc_info: - uninstall_package_command(package_name="test-package") - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.package.PackageTracker") - @patch("devsync.cli.package.find_project_root") - def test_uninstall_package_not_found(self, mock_find_root: MagicMock, mock_tracker_class: MagicMock) -> None: - """Test uninstalling non-existent package.""" - project_root = Path("/project") - mock_find_root.return_value = project_root - - mock_tracker = MagicMock() - mock_tracker.get_package.return_value = None - mock_tracker_class.return_value = mock_tracker - - with pytest.raises(Exit) as exc_info: - uninstall_package_command(package_name="nonexistent-package") - - assert exc_info.value.exit_code == 1 - - # Note: Success path tests removed - Typer commands with Option decorators - # cannot be called directly - they need CliRunner. Error paths test the critical logic. diff --git a/tests/unit/cli/test_update.py b/tests/unit/cli/test_update.py deleted file mode 100644 index d2f55c9..0000000 --- a/tests/unit/cli/test_update.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Unit tests for update CLI command.""" - -from datetime import datetime -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from devsync.cli.update import _extract_ref_from_namespace, _update_installed_instructions, update_repository -from devsync.core.git_operations import RepositoryOperationError -from devsync.core.models import ( - AIToolType, - InstallationRecord, - InstallationScope, - Instruction, - LibraryInstruction, - LibraryRepository, - RefType, -) - - -class TestUpdateRepository: - """Test update_repository function.""" - - @patch("devsync.cli.update.LibraryManager") - def test_update_no_namespace_no_all(self, mock_library_class: MagicMock) -> None: - """Test updating without namespace or --all flag.""" - result = update_repository(namespace=None, all_repos=False) - - assert result == 1 # Error code - - @patch("devsync.cli.update.LibraryManager") - def test_update_all_empty_library(self, mock_library_class: MagicMock) -> None: - """Test updating all when library is empty.""" - mock_library = MagicMock() - mock_library.list_repositories.return_value = [] - mock_library_class.return_value = mock_library - - result = update_repository(all_repos=True) - - assert result == 0 # Success (nothing to update) - - @patch("devsync.cli.update.LibraryManager") - def test_update_namespace_not_found(self, mock_library_class: MagicMock) -> None: - """Test updating non-existent namespace.""" - mock_library = MagicMock() - mock_library.get_repository.return_value = None - mock_library_class.return_value = mock_library - - result = update_repository(namespace="nonexistent") - - assert result == 1 # Error code - - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_skip_immutable_tag(self, mock_library_class: MagicMock, mock_tracker_class: MagicMock) -> None: - """Test updating repository with tag reference (should skip).""" - # Create repository with tag reference in namespace - repo = LibraryRepository( - namespace="test/repo@v1.0.0", # Tag reference - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - mock_library = MagicMock() - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = update_repository(namespace="test/repo@v1.0.0") - - assert result == 0 # Success (skipped) - - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_skip_immutable_commit(self, mock_library_class: MagicMock, mock_tracker_class: MagicMock) -> None: - """Test updating repository with commit reference (should skip).""" - # Create repository with commit reference in namespace - repo = LibraryRepository( - namespace="test/repo@abc123def", # Commit hash - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - mock_library = MagicMock() - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = update_repository(namespace="test/repo@abc123def") - - assert result == 0 # Success (skipped) - - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_repo_directory_not_found( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, tmp_path: Path - ) -> None: - """Test updating when repository directory doesn't exist.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = update_repository(namespace="test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_skip_non_git_repository( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, tmp_path: Path - ) -> None: - """Test updating local non-git repository (should skip).""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - # No .git directory - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - result = update_repository(namespace="test/repo") - - assert result == 0 # Success (skipped) - - @patch("devsync.cli.update.GitOperations.check_for_updates") - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_already_up_to_date( - self, - mock_library_class: MagicMock, - mock_tracker_class: MagicMock, - mock_repo_class: MagicMock, - mock_check_updates: MagicMock, - tmp_path: Path, - ) -> None: - """Test updating when repository is already up to date.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Mock git repository - mock_git_repo = MagicMock() - mock_git_repo.active_branch.name = "main" - mock_repo_class.return_value = mock_git_repo - - # No updates available - mock_check_updates.return_value = False - - result = update_repository(namespace="test/repo") - - assert result == 0 # Success (no updates) - - @pytest.mark.skip(reason="Needs investigation - mock setup issue") - @patch("devsync.cli.update._update_installed_instructions") - @patch("devsync.cli.update.RepositoryParser") - @patch("devsync.cli.update.GitOperations.pull_repository_updates") - @patch("devsync.cli.update.GitOperations.check_for_updates") - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_success( - self, - mock_library_class: MagicMock, - mock_tracker_class: MagicMock, - mock_repo_class: MagicMock, - mock_check_updates: MagicMock, - mock_pull_updates: MagicMock, - mock_parser_class: MagicMock, - mock_update_installed: MagicMock, - tmp_path: Path, - ) -> None: - """Test successful repository update.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - # Create instruction file - inst_file = repo_dir / "test.md" - inst_file.write_text("# Test") - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library.add_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Mock git repository - mock_git_repo = MagicMock() - mock_git_repo.active_branch.name = "main" - mock_repo_class.return_value = mock_git_repo - - # Updates available - mock_check_updates.return_value = True - - # Pull succeeds - mock_pull_updates.return_value = {"success": True} - - # Mock repository parser - instruction = Instruction(name="test", description="Test", content="# Test", file_path="test.md", tags=[]) - - mock_repo_parsed = MagicMock() - mock_repo_parsed.instructions = [instruction] - mock_repo_parsed.metadata = {"name": "Test Repo", "version": "1.0.0", "author": "Test"} - - mock_parser = MagicMock() - mock_parser.parse.return_value = mock_repo_parsed - mock_parser_class.return_value = mock_parser - - result = update_repository(namespace="test/repo") - - assert result == 0 # Success - mock_library.add_repository.assert_called_once() - mock_update_installed.assert_called_once() - - @patch("devsync.cli.update.GitOperations.pull_repository_updates") - @patch("devsync.cli.update.GitOperations.check_for_updates") - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_pull_local_modifications( - self, - mock_library_class: MagicMock, - mock_tracker_class: MagicMock, - mock_repo_class: MagicMock, - mock_check_updates: MagicMock, - mock_pull_updates: MagicMock, - tmp_path: Path, - ) -> None: - """Test updating when pull fails due to local modifications.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Mock git repository - mock_git_repo = MagicMock() - mock_git_repo.active_branch.name = "main" - mock_repo_class.return_value = mock_git_repo - - # Updates available - mock_check_updates.return_value = True - - # Pull fails with local modifications - mock_pull_updates.return_value = { - "success": False, - "error": "local_modifications", - "message": "Local modifications detected", - } - - result = update_repository(namespace="test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.update.GitOperations.pull_repository_updates") - @patch("devsync.cli.update.GitOperations.check_for_updates") - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_pull_conflict( - self, - mock_library_class: MagicMock, - mock_tracker_class: MagicMock, - mock_repo_class: MagicMock, - mock_check_updates: MagicMock, - mock_pull_updates: MagicMock, - tmp_path: Path, - ) -> None: - """Test updating when pull fails due to merge conflict.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Mock git repository - mock_git_repo = MagicMock() - mock_git_repo.active_branch.name = "main" - mock_repo_class.return_value = mock_git_repo - - # Updates available - mock_check_updates.return_value = True - - # Pull fails with conflict - mock_pull_updates.return_value = {"success": False, "error": "conflict", "message": "Merge conflict"} - - result = update_repository(namespace="test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_repository_operation_error( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_repo_class: MagicMock, tmp_path: Path - ) -> None: - """Test updating when RepositoryOperationError occurs.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Repo raises RepositoryOperationError - mock_repo_class.side_effect = RepositoryOperationError("network_error", "Connection failed") - - result = update_repository(namespace="test/repo") - - assert result == 1 # Error code - - @patch("devsync.cli.update.Repo") - @patch("devsync.cli.update.InstallationTracker") - @patch("devsync.cli.update.LibraryManager") - def test_update_generic_exception( - self, mock_library_class: MagicMock, mock_tracker_class: MagicMock, mock_repo_class: MagicMock, tmp_path: Path - ) -> None: - """Test updating when generic exception occurs.""" - repo = LibraryRepository( - namespace="test/repo", - name="Test Repo", - description="Test", - url="https://github.com/test/repo", - author="Author", - version="1.0.0", - downloaded_at=datetime.now(), - alias="test-repo", - instructions=[], - ) - - library_dir = tmp_path / "library" - library_dir.mkdir() - repo_dir = library_dir / "test/repo" - repo_dir.mkdir(parents=True) - git_dir = repo_dir / ".git" - git_dir.mkdir() - - mock_library = MagicMock() - mock_library.library_dir = library_dir - mock_library.get_repository.return_value = repo - mock_library_class.return_value = mock_library - - mock_tracker = MagicMock() - mock_tracker_class.return_value = mock_tracker - - # Repo raises generic exception - mock_repo_class.side_effect = RuntimeError("Unexpected error") - - result = update_repository(namespace="test/repo") - - assert result == 1 # Error code - - -class TestExtractRefFromNamespace: - """Test _extract_ref_from_namespace helper function.""" - - def test_extract_no_ref(self) -> None: - """Test extracting from namespace without reference.""" - ref, ref_type = _extract_ref_from_namespace("test/repo") - - assert ref is None - assert ref_type is None - - def test_extract_tag_ref(self) -> None: - """Test extracting tag reference.""" - ref, ref_type = _extract_ref_from_namespace("test/repo@v1.0.0") - - assert ref == "v1.0.0" - assert ref_type == RefType.TAG - - def test_extract_tag_ref_no_v(self) -> None: - """Test extracting tag reference without 'v' prefix.""" - ref, ref_type = _extract_ref_from_namespace("test/repo@1.2.3") - - assert ref == "1.2.3" - assert ref_type == RefType.TAG - - def test_extract_commit_ref(self) -> None: - """Test extracting commit hash.""" - ref, ref_type = _extract_ref_from_namespace("test/repo@abc123def456") - - assert ref == "abc123def456" - assert ref_type == RefType.COMMIT - - def test_extract_branch_ref(self) -> None: - """Test extracting branch reference.""" - ref, ref_type = _extract_ref_from_namespace("test/repo@feature-branch") - - assert ref == "feature-branch" - assert ref_type == RefType.BRANCH - - -class TestUpdateInstalledInstructions: - """Test _update_installed_instructions helper function.""" - - @patch("devsync.cli.update.find_project_root") - def test_update_installed_no_records(self, mock_find_root: MagicMock, tmp_path: Path) -> None: - """Test updating when no instructions are installed.""" - mock_find_root.return_value = tmp_path - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = [] - - lib_instructions = [] - - _update_installed_instructions("test/repo", lib_instructions, mock_tracker) - - # Should complete without error - - @patch("devsync.cli.update.find_project_root") - def test_update_installed_success(self, mock_find_root: MagicMock, tmp_path: Path) -> None: - """Test updating installed instruction files.""" - project_root = tmp_path / "project" - project_root.mkdir() - mock_find_root.return_value = project_root - - # Create installed file - installed_file = project_root / ".cursor" / "rules" / "test.mdc" - installed_file.parent.mkdir(parents=True) - installed_file.write_text("old content") - - # Create library instruction file - library_file = tmp_path / "library_test.md" - library_file.write_text("new content") - - # Setup tracker with installation record - record = InstallationRecord( - instruction_name="test", - ai_tool=AIToolType.CURSOR, - source_repo="https://github.com/test/repo", - installed_path=str(installed_file), - installed_at=datetime.now(), - scope=InstallationScope.PROJECT, - ) - - mock_tracker = MagicMock() - mock_tracker.get_installed_instructions.return_value = [record] - - # Setup library instruction - lib_inst = LibraryInstruction( - id="test/repo/test", - name="test", - description="Test", - repo_namespace="test/repo", - repo_url="https://github.com/test/repo", - repo_name="Test Repo", - author="Author", - version="1.0.0", - file_path=str(library_file), - tags=[], - ) - - _update_installed_instructions("test/repo", [lib_inst], mock_tracker) - - # Verify file was updated - assert installed_file.read_text() == "new content" diff --git a/tests/unit/packages/test_package_create_cli.py b/tests/unit/packages/test_package_create_cli.py deleted file mode 100644 index 5e8e669..0000000 --- a/tests/unit/packages/test_package_create_cli.py +++ /dev/null @@ -1,367 +0,0 @@ -"""Unit tests for package create CLI command.""" - -import json -from pathlib import Path -from unittest.mock import patch - -import pytest -from typer.testing import CliRunner - -from devsync.cli.main import app - -runner = CliRunner() - - -@pytest.fixture -def temp_project(tmp_path: Path) -> Path: - """Create a temporary project with a git marker.""" - project = tmp_path / "project" - project.mkdir() - (project / ".git").mkdir() - return project - - -@pytest.fixture -def project_with_instruction(temp_project: Path) -> Path: - """Create a project with an instruction file.""" - rules_dir = temp_project / ".claude" / "rules" - rules_dir.mkdir(parents=True) - (rules_dir / "coding-style.md").write_text("# Coding Style\nBe consistent.") - return temp_project - - -class TestPackageCreateCommand: - """Test aiconfig package create command.""" - - def test_create_no_components(self, temp_project: Path) -> None: - """Test create fails with no components.""" - with patch("devsync.cli.package_create.find_project_root", return_value=temp_project): - result = runner.invoke(app, ["package", "create", "--no-interactive", "--name", "test"]) - - assert result.exit_code != 0 - assert "No packageable components" in result.output - - def test_create_basic_package(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test basic package creation.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "test-pkg", - "--output", - str(output_dir), - "--description", - "Test package", - "--author", - "Tester", - ], - ) - - assert result.exit_code == 0, f"Failed with: {result.output}" - assert "successfully" in result.output.lower() - - package_dir = output_dir / "package-test-pkg" - assert package_dir.exists() - assert (package_dir / "ai-config-kit-package.yaml").exists() - - def test_create_json_output(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test JSON output format.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "json-test", - "--output", - str(output_dir), - "--json", - "--quiet", # Suppress non-JSON output - ], - ) - - assert result.exit_code == 0 - # Verify key JSON fields are present in output - assert '"success": true' in result.output or '"success":true' in result.output - assert '"package_path"' in result.output - assert '"components_included"' in result.output - - # Verify the package was actually created - package_dir = output_dir / "package-json-test" - assert package_dir.exists() - assert (package_dir / "ai-config-kit-package.yaml").exists() - - def test_create_requires_name_non_interactive(self, project_with_instruction: Path) -> None: - """Test that --name is required in non-interactive mode.""" - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - ["package", "create", "--no-interactive"], - ) - - assert result.exit_code != 0 - assert "--name is required" in result.output - - def test_create_invalid_name(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test rejection of invalid package names.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "invalid@name!", - "--output", - str(output_dir), - ], - ) - - assert result.exit_code != 0 - assert "Invalid package name" in result.output - - def test_create_existing_directory(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test failure when package directory exists.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - (output_dir / "package-existing").mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "existing", - "--output", - str(output_dir), - ], - ) - - assert result.exit_code != 0 - assert "already exists" in result.output - - def test_create_force_overwrite(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test --force overwrites existing directory.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - existing = output_dir / "package-force-test" - existing.mkdir() - (existing / "old-file.txt").write_text("old content") - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "force-test", - "--output", - str(output_dir), - "--force", - ], - ) - - assert result.exit_code == 0 - assert (existing / "ai-config-kit-package.yaml").exists() - assert not (existing / "old-file.txt").exists() - - def test_create_quiet_mode(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test quiet output mode.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "quiet-test", - "--output", - str(output_dir), - "--quiet", - ], - ) - - assert result.exit_code == 0 - assert "Components:" not in result.output - - def test_create_custom_version(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test custom version number.""" - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "version-test", - "--version", - "2.5.0", - "--output", - str(output_dir), - ], - ) - - assert result.exit_code == 0 - - import yaml - - manifest_path = output_dir / "package-version-test" / "ai-config-kit-package.yaml" - with open(manifest_path) as f: - manifest = yaml.safe_load(f) - assert manifest["version"] == "2.5.0" - - def test_create_keep_secrets(self, project_with_instruction: Path, tmp_path: Path) -> None: - """Test --keep-secrets flag.""" - claude_dir = project_with_instruction / ".claude" - settings = {"mcpServers": {"test": {"command": "cmd", "env": {"API_KEY": "secret-value-123456789"}}}} - (claude_dir / "settings.local.json").write_text(json.dumps(settings)) - - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "secrets-test", - "--output", - str(output_dir), - "--keep-secrets", - ], - ) - - assert result.exit_code == 0 - - mcp_file = output_dir / "package-secrets-test" / "mcp" / "test.json" - if mcp_file.exists(): - with open(mcp_file) as f: - mcp_config = json.load(f) - if "env" in mcp_config: - assert mcp_config["env"]["API_KEY"] == "secret-value-123456789" - - def test_create_invalid_output_directory(self, project_with_instruction: Path) -> None: - """Test failure with invalid output directory.""" - with patch("devsync.cli.package_create.find_project_root", return_value=project_with_instruction): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "test", - "--output", - "/nonexistent/path", - ], - ) - - assert result.exit_code != 0 - assert "not found" in result.output.lower() - - -class TestPackageCreateComponents: - """Test package creation with various component types.""" - - def test_create_with_multiple_instructions(self, temp_project: Path, tmp_path: Path) -> None: - """Test package with multiple instruction files.""" - rules_dir = temp_project / ".claude" / "rules" - rules_dir.mkdir(parents=True) - (rules_dir / "style.md").write_text("# Style") - (rules_dir / "testing.md").write_text("# Testing") - (rules_dir / "security.md").write_text("# Security") - - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=temp_project): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "multi-inst", - "--output", - str(output_dir), - ], - ) - - assert result.exit_code == 0 - - import yaml - - manifest_path = output_dir / "package-multi-inst" / "ai-config-kit-package.yaml" - with open(manifest_path) as f: - manifest = yaml.safe_load(f) - assert len(manifest["components"]["instructions"]) == 3 - - def test_create_with_hooks_and_commands(self, temp_project: Path, tmp_path: Path) -> None: - """Test package with hooks and commands.""" - (temp_project / ".claude" / "rules").mkdir(parents=True) - (temp_project / ".claude" / "rules" / "test.md").write_text("# Test") - - hooks_dir = temp_project / ".claude" / "hooks" - hooks_dir.mkdir(parents=True) - (hooks_dir / "preToolUse.sh").write_text("#!/bin/bash") - - cmd_dir = temp_project / ".claude" / "commands" - cmd_dir.mkdir(parents=True) - (cmd_dir / "build.sh").write_text("#!/bin/bash") - - output_dir = tmp_path / "output" - output_dir.mkdir() - - with patch("devsync.cli.package_create.find_project_root", return_value=temp_project): - result = runner.invoke( - app, - [ - "package", - "create", - "--no-interactive", - "--name", - "full-pkg", - "--output", - str(output_dir), - ], - ) - - assert result.exit_code == 0 - - package_dir = output_dir / "package-full-pkg" - assert (package_dir / "hooks" / "preToolUse.sh").exists() - assert (package_dir / "commands" / "build.sh").exists() diff --git a/tests/unit/packages/test_package_install_coverage.py b/tests/unit/packages/test_package_install_coverage.py deleted file mode 100644 index 2d770b1..0000000 --- a/tests/unit/packages/test_package_install_coverage.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Additional tests for package installation to increase code coverage.""" - -from pathlib import Path - -import pytest - -from devsync.ai_tools.capability_registry import get_capability -from devsync.ai_tools.translator import get_translator -from devsync.cli.package_install import ( - _filter_components_by_capability, - _install_command_component, - _install_hook_component, - _install_mcp_component, - _install_resource_component, -) -from devsync.core.models import ( - AIToolType, - CommandComponent, - ConflictResolution, - HookComponent, - MCPServerComponent, - Package, - PackageComponents, - ResourceComponent, -) - - -class TestFilterComponentsByCapability: - """Test component filtering by IDE capability.""" - - def test_filter_all_components_for_claude(self) -> None: - """Test that Claude Code supports all component types.""" - from devsync.core.models import InstructionComponent - - package = Package( - name="test", - version="1.0.0", - description="Test", - author="Test", - namespace="test/test", - license="MIT", - components=PackageComponents( - instructions=[InstructionComponent(name="i1", file="f1", description="d1")], - mcp_servers=[MCPServerComponent(name="m1", file="mf1", description="md1")], - hooks=[HookComponent(name="h1", file="hf1", description="hd1", hook_type="pre-commit")], - commands=[CommandComponent(name="c1", file="cf1", description="cd1", command_type="shell")], - resources=[ - ResourceComponent( - name="r1", file="rf1", description="rd1", install_path="r1", checksum="sha256:abc", size=100 - ) - ], - ), - ) - - capability = get_capability(AIToolType.CLAUDE) - filtered = _filter_components_by_capability(package, capability) - - assert len(filtered["instructions"]) == 1 - assert len(filtered["mcp_servers"]) == 1 - assert len(filtered["hooks"]) == 1 - assert len(filtered["commands"]) == 1 - assert len(filtered["resources"]) == 1 - - def test_filter_components_for_cursor(self) -> None: - """Test that Cursor supports instructions, MCP servers, and resources.""" - from devsync.core.models import InstructionComponent - - package = Package( - name="test", - version="1.0.0", - description="Test", - author="Test", - namespace="test/test", - license="MIT", - components=PackageComponents( - instructions=[InstructionComponent(name="i1", file="f1", description="d1")], - mcp_servers=[MCPServerComponent(name="m1", file="mf1", description="md1")], - hooks=[HookComponent(name="h1", file="hf1", description="hd1", hook_type="pre-commit")], - commands=[CommandComponent(name="c1", file="cf1", description="cd1", command_type="shell")], - resources=[ - ResourceComponent( - name="r1", file="rf1", description="rd1", install_path="r1", checksum="sha256:abc", size=100 - ) - ], - ), - ) - - capability = get_capability(AIToolType.CURSOR) - filtered = _filter_components_by_capability(package, capability) - - assert len(filtered["instructions"]) == 1 - assert len(filtered.get("mcp_servers", [])) == 1 # Cursor now supports MCP - assert len(filtered.get("hooks", [])) == 0 # Hooks still not supported - assert len(filtered.get("commands", [])) == 0 # Commands still not supported - assert len(filtered["resources"]) == 1 - - -class TestComponentInstallation: - """Test individual component installation functions.""" - - @pytest.fixture - def temp_package(self, tmp_path: Path) -> Path: - """Create a temporary package directory.""" - pkg_path = tmp_path / "package" - pkg_path.mkdir() - - # Create component files - (pkg_path / "mcp").mkdir() - (pkg_path / "mcp/test.json").write_text('{"mcpServers": {}}') - - (pkg_path / "hooks").mkdir() - (pkg_path / "hooks/test.sh").write_text("#!/bin/bash\necho test") - - (pkg_path / "commands").mkdir() - (pkg_path / "commands/test.sh").write_text("#!/bin/bash\necho test") - - (pkg_path / "resources").mkdir() - (pkg_path / "resources/test.txt").write_text("test content") - - return pkg_path - - @pytest.fixture - def temp_project(self, tmp_path: Path) -> Path: - """Create a temporary project directory.""" - project = tmp_path / "project" - project.mkdir() - return project - - def test_install_mcp_with_overwrite(self, temp_package: Path, temp_project: Path) -> None: - """Test MCP installation with OVERWRITE conflict resolution.""" - component = MCPServerComponent(name="test", file="mcp/test.json", description="Test") - translator = get_translator(AIToolType.CLAUDE) - - # Create existing file - existing_file = temp_project / ".claude/mcp/test.json" - existing_file.parent.mkdir(parents=True, exist_ok=True) - existing_file.write_text('{"old": "content"}') - - # Install with overwrite - result = _install_mcp_component(component, temp_package, temp_project, translator, ConflictResolution.OVERWRITE) - - assert result is not None - assert result.name == "test" - # New content should overwrite old - assert existing_file.read_text() == '{"mcpServers": {}}' - - def test_install_hook_with_rename(self, temp_package: Path, temp_project: Path) -> None: - """Test hook installation with RENAME conflict resolution.""" - component = HookComponent(name="test", file="hooks/test.sh", description="Test", hook_type="pre-commit") - translator = get_translator(AIToolType.CLAUDE) - - # Create existing file - existing_file = temp_project / ".claude/hooks/test.sh" - existing_file.parent.mkdir(parents=True, exist_ok=True) - existing_file.write_text("old content") - - # Install with rename - result = _install_hook_component(component, temp_package, temp_project, translator, ConflictResolution.RENAME) - - assert result is not None - # Should create numbered copy - renamed_file = temp_project / ".claude/hooks/test-1.sh" - assert renamed_file.exists() - assert existing_file.read_text() == "old content" # Original preserved - - def test_install_command_error_handling(self, temp_package: Path, temp_project: Path) -> None: - """Test command installation error handling.""" - # Component with non-existent file - component = CommandComponent( - name="missing", file="commands/missing.sh", description="Missing", command_type="shell" - ) - translator = get_translator(AIToolType.CLAUDE) - - result = _install_command_component(component, temp_package, temp_project, translator, ConflictResolution.SKIP) - - # Should return None on error - assert result is None - - def test_install_resource_with_skip(self, temp_package: Path, temp_project: Path) -> None: - """Test resource installation with SKIP conflict resolution.""" - component = ResourceComponent( - name="test", - file="resources/test.txt", - description="Test", - install_path=".testfile", - checksum="sha256:abc", - size=100, - ) - translator = get_translator(AIToolType.CLAUDE) - - # Create existing file - existing_file = temp_project / ".testfile" - existing_file.write_text("existing content") - - # Install with skip - result = _install_resource_component(component, temp_package, temp_project, translator, ConflictResolution.SKIP) - - # Should return None (skipped) - assert result is None - # Original file preserved - assert existing_file.read_text() == "existing content" - - def test_install_resource_without_source_path(self, temp_package: Path, temp_project: Path) -> None: - """Test resource installation fallback when no source_path in metadata.""" - from unittest.mock import MagicMock - - from devsync.ai_tools.translator import TranslatedComponent - - component = ResourceComponent( - name="test", - file="resources/test.txt", - description="Test", - install_path=".testfile", - checksum="sha256:abc", - size=100, - ) - - # Create mock translator that returns TranslatedComponent without source_path - from devsync.core.models import ComponentType - - translator = MagicMock() - translator.translate_resource.return_value = TranslatedComponent( - component_type=ComponentType.RESOURCE, - component_name="test", - target_path=".testfile", - content="test content", - metadata={}, # No source_path - triggers fallback - ) - - # Install resource - result = _install_resource_component( - component, temp_package, temp_project, translator, ConflictResolution.OVERWRITE - ) - - # Should install successfully using content fallback - assert result is not None - assert result.name == "test" - # File should contain the content - installed_file = temp_project / ".testfile" - assert installed_file.exists() - assert installed_file.read_text() == "test content" diff --git a/tests/unit/test_cli_delete.py b/tests/unit/test_cli_delete.py deleted file mode 100644 index a9a028b..0000000 --- a/tests/unit/test_cli_delete.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Tests for delete CLI command.""" - -from datetime import datetime -from unittest.mock import Mock, patch - -import pytest - -from devsync.cli.delete import delete_from_library -from devsync.core.models import LibraryInstruction, LibraryRepository -from devsync.storage.library import LibraryManager -from devsync.storage.tracker import InstallationTracker - - -@pytest.fixture -def mock_library_manager(monkeypatch: pytest.MonkeyPatch) -> Mock: - """Create a mock library manager.""" - mock = Mock(spec=LibraryManager) - monkeypatch.setattr("devsync.cli.delete.LibraryManager", lambda: mock) - return mock - - -@pytest.fixture -def mock_tracker(monkeypatch: pytest.MonkeyPatch) -> Mock: - """Create a mock installation tracker.""" - mock = Mock(spec=InstallationTracker) - monkeypatch.setattr("devsync.cli.delete.InstallationTracker", lambda: mock) - return mock - - -def test_delete_repository_not_found(mock_library_manager: Mock, mock_tracker: Mock) -> None: - """Test deleting a repository that doesn't exist.""" - mock_library_manager.get_repository.return_value = None - - exit_code = delete_from_library("nonexistent", force=True) - - assert exit_code == 1 - mock_library_manager.get_repository.assert_called_once_with("nonexistent") - - -def test_delete_repository_with_force(mock_library_manager: Mock, mock_tracker: Mock) -> None: - """Test deleting a repository with force flag (no confirmation).""" - # Create mock repository - repo = LibraryRepository( - name="Test Repo", - namespace="test-namespace", - version="1.0.0", - description="Test repository", - instructions=[], - url="https://test.com/repo", - author="Test Author", - downloaded_at=datetime.now(), - ) - mock_library_manager.get_repository.return_value = repo - mock_library_manager.remove_repository.return_value = True - mock_tracker.list_installations.return_value = [] - - exit_code = delete_from_library("test-namespace", force=True) - - assert exit_code == 0 - mock_library_manager.remove_repository.assert_called_once_with("test-namespace") - - -def test_delete_repository_with_installed_instructions(mock_library_manager: Mock, mock_tracker: Mock) -> None: - """Test deleting a repository that has installed instructions.""" - # Create mock repository with instructions - instruction = LibraryInstruction( - id="test-namespace/test-instruction", - name="test-instruction", - description="Test instruction", - file_path="/tmp/instruction.md", - tags=["test"], - repo_namespace="test-namespace", - repo_url="https://test.com/repo", - repo_name="Test Repo", - author="Test Author", - version="1.0.0", - ) - repo = LibraryRepository( - name="Test Repo", - namespace="test-namespace", - version="1.0.0", - description="Test repository", - instructions=[instruction], - url="https://test.com/repo", - author="Test Author", - downloaded_at=datetime.now(), - ) - mock_library_manager.get_repository.return_value = repo - mock_library_manager.remove_repository.return_value = True - - # Mock installed instructions (empty for force delete) - mock_tracker.list_installations.return_value = [] - - exit_code = delete_from_library("test-namespace", force=True) - - assert exit_code == 0 - - -def test_delete_repository_failure(mock_library_manager: Mock, mock_tracker: Mock) -> None: - """Test when repository deletion fails.""" - repo = LibraryRepository( - name="Test Repo", - namespace="test-namespace", - version="1.0.0", - description="Test repository", - instructions=[], - url="https://test.com/repo", - author="Test Author", - downloaded_at=datetime.now(), - ) - mock_library_manager.get_repository.return_value = repo - mock_library_manager.remove_repository.return_value = False - mock_tracker.list_installations.return_value = [] - - exit_code = delete_from_library("test-namespace", force=True) - - assert exit_code == 1 - - -@patch("devsync.cli.delete.Confirm.ask") -def test_delete_repository_with_confirmation_cancelled( - mock_confirm: Mock, mock_library_manager: Mock, mock_tracker: Mock -) -> None: - """Test deleting a repository when user cancels confirmation.""" - mock_confirm.return_value = False - - repo = LibraryRepository( - name="Test Repo", - namespace="test-namespace", - version="1.0.0", - description="Test repository", - instructions=[], - url="https://test.com/repo", - author="Test Author", - downloaded_at=datetime.now(), - ) - mock_library_manager.get_repository.return_value = repo - mock_tracker.list_installations.return_value = [] - - exit_code = delete_from_library("test-namespace", force=False) - - assert exit_code == 0 - mock_library_manager.remove_repository.assert_not_called() - - -@patch("devsync.cli.delete.Confirm.ask") -def test_delete_repository_with_confirmation_accepted( - mock_confirm: Mock, mock_library_manager: Mock, mock_tracker: Mock -) -> None: - """Test deleting a repository when user confirms.""" - mock_confirm.return_value = True - - repo = LibraryRepository( - name="Test Repo", - namespace="test-namespace", - version="1.0.0", - description="Test repository", - instructions=[], - url="https://test.com/repo", - author="Test Author", - downloaded_at=datetime.now(), - ) - mock_library_manager.get_repository.return_value = repo - mock_library_manager.remove_repository.return_value = True - mock_tracker.list_installations.return_value = [] - - exit_code = delete_from_library("test-namespace", force=False) - - assert exit_code == 0 - mock_library_manager.remove_repository.assert_called_once_with("test-namespace") diff --git a/tests/unit/test_cli_install_new.py b/tests/unit/test_cli_install_new.py deleted file mode 100644 index e7d112d..0000000 --- a/tests/unit/test_cli_install_new.py +++ /dev/null @@ -1,673 +0,0 @@ -"""Tests for install_new CLI command.""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from devsync.cli.install_new import ( - _check_for_upgrades, - _detect_installed_collisions, - _prompt_for_upgrade, - install_from_library_direct_multi_tool, - install_from_library_tui, -) -from devsync.core.models import AIToolType, InstallationRecord, LibraryInstruction, RefType - - -@pytest.fixture -def mock_library_instruction(): - """Create a mock LibraryInstruction.""" - return LibraryInstruction( - id="test-repo/python-style", - name="python-style", - description="Python style guidelines", - file_path="/fake/path/python-style.md", - checksum="abc123", - tags=["python", "style"], - repo_name="test-repo", - repo_namespace="test", - repo_url="https://github.com/test/test-repo.git", - version="1.0.0", - author="Test Author", - ) - - -@pytest.fixture -def mock_library_instructions(mock_library_instruction): - """Create a list of mock LibraryInstructions.""" - inst1 = mock_library_instruction - inst2 = LibraryInstruction( - id="test-repo/testing-guide", - name="testing-guide", - description="Testing best practices", - file_path="/fake/path/testing-guide.md", - checksum="def456", - tags=["testing"], - repo_name="test-repo", - repo_namespace="test", - repo_url="https://github.com/test/test-repo.git", - version="1.0.0", - author="Test Author", - ) - return [inst1, inst2] - - -@pytest.fixture -def mock_project_with_git(temp_dir): - """Create a mock project with .git directory.""" - project = temp_dir / "test-project" - project.mkdir() - (project / ".git").mkdir() - return project - - -class TestInstallFromLibraryTUI: - """Tests for install_from_library_tui function.""" - - @patch("devsync.cli.install_new.install_from_library_direct_multi_tool") - @patch("devsync.cli.install_new.show_installer_tui") - @patch("devsync.cli.install_new.LibraryManager") - def test_install_from_tui_no_duplicate_confirmation( - self, - mock_library_manager, - mock_tui, - mock_install_direct, - mock_library_instructions, - ): - """Test that install_from_library_tui doesn't show duplicate confirmation. - - This is the main test for issue #1. It verifies that when the TUI returns - selected instructions, the function doesn't show its own confirmation dialog - but instead delegates to install_from_library_direct_multi_tool which - handles the single confirmation. - """ - # Setup mocks - mock_library_manager.return_value.list_instructions.return_value = mock_library_instructions - - # Mock TUI to return selected instructions and tools - mock_tui.return_value = { - "instructions": mock_library_instructions, - "tools": ["copilot", "cursor"], - } - - # Mock the direct install to return success - mock_install_direct.return_value = 0 - - # Call the function - result = install_from_library_tui(tool=None) - - # Verify TUI was shown - mock_tui.assert_called_once() - - # Verify that install_from_library_direct_multi_tool was called - # with the selected instructions and tools - mock_install_direct.assert_called_once_with( - instruction_ids=[inst.id for inst in mock_library_instructions], - tools=["copilot", "cursor"], - conflict_strategy="skip", - ) - - # Verify success - assert result == 0 - - @patch("devsync.cli.install_new.show_installer_tui") - @patch("devsync.cli.install_new.LibraryManager") - def test_install_from_tui_cancelled(self, mock_library_manager, mock_tui, mock_library_instructions): - """Test that cancelling from TUI returns gracefully.""" - mock_library_manager.return_value.list_instructions.return_value = mock_library_instructions - - # Mock TUI to return None (cancelled) - mock_tui.return_value = None - - result = install_from_library_tui(tool=None) - - # Should return 0 (success, just cancelled) - assert result == 0 - - @patch("devsync.cli.install_new.LibraryManager") - def test_install_from_tui_empty_library(self, mock_library_manager): - """Test that empty library shows info message.""" - mock_library_manager.return_value.list_instructions.return_value = [] - - result = install_from_library_tui(tool=None) - - # Should return 1 (error - no instructions) - assert result == 1 - - @patch("devsync.cli.install_new.install_from_library_direct_multi_tool") - @patch("devsync.cli.install_new.show_installer_tui") - @patch("devsync.cli.install_new.LibraryManager") - def test_install_from_tui_delegates_to_direct_install( - self, mock_library_manager, mock_tui, mock_install_direct, mock_library_instructions - ): - """Test that TUI delegates to direct install function.""" - mock_library_manager.return_value.list_instructions.return_value = mock_library_instructions - - # Mock TUI to return selections - mock_tui.return_value = { - "instructions": mock_library_instructions, - "tools": ["copilot"], - } - - # Mock direct install handles project root checking - mock_install_direct.return_value = 1 - - result = install_from_library_tui(tool=None) - - # Should delegate to direct install - mock_install_direct.assert_called_once() - assert result == 1 - - -class TestInstallFromLibraryDirectMultiTool: - """Tests for install_from_library_direct_multi_tool function.""" - - @patch("devsync.cli.install_new._show_installation_preview") - @patch("devsync.cli.install_new._perform_installation") - @patch("devsync.cli.install_new._get_ai_tools_from_names") - @patch("devsync.cli.install_new._resolve_name_conflicts") - @patch("devsync.cli.install_new._load_instructions_from_library") - @patch("devsync.cli.install_new._get_project_root_for_installation") - @patch("devsync.cli.install_new.get_detector") - @patch("devsync.cli.install_new.LibraryManager") - def test_confirmation_prompt_called_once( - self, - mock_library_manager, - mock_get_detector, - mock_get_root, - mock_load_instructions, - mock_resolve_conflicts, - mock_get_tools, - mock_perform_install, - mock_show_preview, - mock_library_instructions, - mock_project_with_git, - ): - """Test that confirmation prompt is called exactly once. - - This test verifies that _show_installation_preview (which contains - the confirmation prompt) is called exactly once, not twice. - """ - # Setup mocks - mock_get_root.return_value = mock_project_with_git - mock_load_instructions.return_value = mock_library_instructions - mock_resolve_conflicts.return_value = {inst.id: inst.name for inst in mock_library_instructions} - - # Mock AI tools - mock_tool = MagicMock() - mock_tool.tool_name = "copilot" - mock_get_tools.return_value = [mock_tool] - - # Mock preview to return True (confirmed) - mock_show_preview.return_value = True - - # Mock installation - mock_perform_install.return_value = (2, 0) # 2 installed, 0 skipped - - # Call the function - result = install_from_library_direct_multi_tool( - instruction_ids=[inst.id for inst in mock_library_instructions], - tools=["copilot"], - conflict_strategy="skip", - ) - - # Verify preview/confirmation was called EXACTLY ONCE - assert mock_show_preview.call_count == 1 - - # Verify success - assert result == 0 - - @patch("devsync.cli.install_new._show_installation_preview") - @patch("devsync.cli.install_new._get_ai_tools_from_names") - @patch("devsync.cli.install_new._resolve_name_conflicts") - @patch("devsync.cli.install_new._load_instructions_from_library") - @patch("devsync.cli.install_new._get_project_root_for_installation") - @patch("devsync.cli.install_new.get_detector") - @patch("devsync.cli.install_new.LibraryManager") - def test_confirmation_denied_cancels_installation( - self, - mock_library_manager, - mock_get_detector, - mock_get_root, - mock_load_instructions, - mock_resolve_conflicts, - mock_get_tools, - mock_show_preview, - mock_library_instructions, - mock_project_with_git, - ): - """Test that denying confirmation cancels installation.""" - # Setup mocks - mock_get_root.return_value = mock_project_with_git - mock_load_instructions.return_value = mock_library_instructions - mock_resolve_conflicts.return_value = {inst.id: inst.name for inst in mock_library_instructions} - - mock_tool = MagicMock() - mock_tool.tool_name = "copilot" - mock_get_tools.return_value = [mock_tool] - - # Mock preview to return False (not confirmed) - mock_show_preview.return_value = False - - # Call the function - result = install_from_library_direct_multi_tool( - instruction_ids=[inst.id for inst in mock_library_instructions], - tools=["copilot"], - conflict_strategy="skip", - ) - - # Verify preview was called - assert mock_show_preview.call_count == 1 - - # Should return 0 (cancelled, not error) - assert result == 0 - - @patch("devsync.cli.install_new._get_project_root_for_installation") - @patch("devsync.cli.install_new.LibraryManager") - def test_invalid_conflict_strategy(self, mock_library_manager, mock_get_root): - """Test that invalid conflict strategy returns error.""" - result = install_from_library_direct_multi_tool( - instruction_ids=["test-id"], - tools=["copilot"], - conflict_strategy="invalid", - ) - - # Should return 1 (error) - assert result == 1 - - @patch("devsync.cli.install_new._load_instructions_from_library") - @patch("devsync.cli.install_new._get_project_root_for_installation") - @patch("devsync.cli.install_new.LibraryManager") - def test_instruction_not_found(self, mock_library_manager, mock_get_root, mock_load_instructions): - """Test that missing instruction returns error.""" - mock_get_root.return_value = Path("/fake/project") - mock_load_instructions.return_value = None # Not found - - result = install_from_library_direct_multi_tool( - instruction_ids=["nonexistent"], - tools=["copilot"], - conflict_strategy="skip", - ) - - # Should return 1 (error) - assert result == 1 - - -class TestInstallationFlow: - """Integration tests for the full installation flow.""" - - @patch("rich.prompt.Confirm.ask") - @patch("devsync.cli.install_new.show_installer_tui") - @patch("devsync.cli.install_new.find_project_root") - @patch("devsync.cli.install_new.get_detector") - @patch("devsync.cli.install_new.LibraryManager") - @patch("devsync.cli.install_new.InstallationTracker") - def test_full_flow_single_confirmation( - self, - mock_tracker, - mock_library_manager, - mock_get_detector, - mock_find_root, - mock_tui, - mock_confirm, - mock_library_instructions, - mock_project_with_git, - temp_dir, - ): - """Test the full installation flow to ensure confirmation is asked only once.""" - # Setup project - mock_find_root.return_value = mock_project_with_git - - # Setup library with instructions - mock_library = MagicMock() - mock_library.list_instructions.return_value = mock_library_instructions - - def get_instruction(inst_id): - for inst in mock_library_instructions: - if inst.id == inst_id: - return inst - return None - - mock_library.get_instruction.side_effect = get_instruction - mock_library_manager.return_value = mock_library - - # Setup AI tool - mock_tool = MagicMock() - mock_tool.tool_name = "copilot" - mock_tool.tool_type = AIToolType.COPILOT - mock_tool.get_project_instructions_directory.return_value = mock_project_with_git / ".github" / "instructions" - mock_tool.get_instruction_file_extension.return_value = ".md" - mock_tool.get_instruction_path.return_value = ( - mock_project_with_git / ".github" / "instructions" / "python-style.md" - ) - - mock_detector = MagicMock() - mock_detector.get_tool_by_name.return_value = mock_tool - mock_get_detector.return_value = mock_detector - - # Setup TUI result - mock_tui.return_value = { - "instructions": mock_library_instructions, - "tools": ["copilot"], - } - - # Mock confirmation - should be called ONCE - mock_confirm.return_value = True - - # Create instruction files in temp dir - for inst in mock_library_instructions: - fake_path = temp_dir / f"{inst.name}.md" - fake_path.write_text(f"# {inst.name}") - inst.file_path = str(fake_path) - - # Setup tracker - mock_tracker_instance = MagicMock() - mock_tracker.return_value = mock_tracker_instance - - # Call install_from_library_tui - result = install_from_library_tui(tool=None) - - # Verify Confirm.ask was called exactly ONCE - # This is the key assertion for issue #1 - before the fix, this would be 2 - assert mock_confirm.call_count == 1, f"Expected confirmation prompt once, but got {mock_confirm.call_count}" - - # Verify the confirmation message - call_args = mock_confirm.call_args - assert "Proceed with installation?" in str(call_args) - - # Verify success - assert result == 0 - - -class TestUpgradeDetection: - """Tests for upgrade detection functionality.""" - - def test_check_for_upgrades_detects_version_change(self, mock_library_instruction, mock_project_with_git): - """Test that upgrade detection finds when version changes.""" - # Create instruction with new version - new_instruction = LibraryInstruction( - id="test-repo@v2.0.0/python-style", - name="python-style", - description="Python style guidelines", - file_path="/fake/path/python-style.md", - checksum="xyz789", - tags=["python"], - repo_name="test-repo", - repo_namespace="test-repo@v2.0.0", - repo_url="https://github.com/test/test-repo.git", - version="2.0.0", - author="Test Author", - ) - - # Mock existing installation - existing_record = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/test/test-repo.git", - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="abc123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - # Mock AI tool - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.get_installation.return_value = existing_record - mock_tracker_class.return_value = mock_tracker - - upgrades = _check_for_upgrades( - instructions=[new_instruction], - ai_tools=[mock_tool], - install_names={new_instruction.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # Should detect upgrade - assert len(upgrades) == 1 - key = f"{new_instruction.id}_{AIToolType.COPILOT.value}" - assert key in upgrades - assert upgrades[key][0] == existing_record - assert upgrades[key][1] == new_instruction - - def test_check_for_upgrades_no_existing_installation(self, mock_library_instruction, mock_project_with_git): - """Test that no upgrade detected when instruction not installed.""" - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.get_installation.return_value = None # Not installed - mock_tracker_class.return_value = mock_tracker - - upgrades = _check_for_upgrades( - instructions=[mock_library_instruction], - ai_tools=[mock_tool], - install_names={mock_library_instruction.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # No upgrades - assert len(upgrades) == 0 - - def test_check_for_upgrades_same_version(self, mock_library_instruction, mock_project_with_git): - """Test that no upgrade detected when version is same.""" - existing_record = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/test/test-repo.git", - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="abc123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - # Create instruction with same version in namespace - same_version_inst = LibraryInstruction( - id="test-repo@v1.0.0/python-style", - name="python-style", - description="Python style guidelines", - file_path="/fake/path/python-style.md", - checksum="abc123", - tags=["python"], - repo_name="test-repo", - repo_namespace="test-repo@v1.0.0", - repo_url="https://github.com/test/test-repo.git", - version="1.0.0", - author="Test Author", - ) - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.get_installation.return_value = existing_record - mock_tracker_class.return_value = mock_tracker - - upgrades = _check_for_upgrades( - instructions=[same_version_inst], - ai_tools=[mock_tool], - install_names={same_version_inst.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # No upgrades (same version) - assert len(upgrades) == 0 - - @patch("rich.prompt.Confirm.ask") - def test_prompt_for_upgrade_user_confirms(self, mock_confirm): - """Test upgrade prompt when user confirms.""" - mock_confirm.return_value = True - - existing = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/test/test-repo.git", - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="abc123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - new_inst = LibraryInstruction( - id="test-repo@v2.0.0/python-style", - name="python-style", - description="Python style guidelines", - file_path="/fake/path/python-style.md", - checksum="xyz789", - tags=["python"], - repo_name="test-repo", - repo_namespace="test-repo@v2.0.0", - repo_url="https://github.com/test/test-repo.git", - version="2.0.0", - author="Test Author", - ) - - result = _prompt_for_upgrade(existing, new_inst) - - assert result is True - mock_confirm.assert_called_once() - - @patch("rich.prompt.Confirm.ask") - def test_prompt_for_upgrade_user_declines(self, mock_confirm): - """Test upgrade prompt when user declines.""" - mock_confirm.return_value = False - - existing = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/test/test-repo.git", - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="abc123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - new_inst = LibraryInstruction( - id="test-repo@v2.0.0/python-style", - name="python-style", - description="Python style guidelines", - file_path="/fake/path/python-style.md", - checksum="xyz789", - tags=["python"], - repo_name="test-repo", - repo_namespace="test-repo@v2.0.0", - repo_url="https://github.com/test/test-repo.git", - version="2.0.0", - author="Test Author", - ) - - result = _prompt_for_upgrade(existing, new_inst) - - assert result is False - - -class TestCollisionDetection: - """Tests for name collision detection.""" - - def test_detect_collision_different_repo(self, mock_library_instruction, mock_project_with_git): - """Test that collision detected when same name from different repo.""" - # Existing installation from different repo - existing_record = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/other/other-repo.git", - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="different123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.find_instructions_by_name.return_value = [existing_record] - mock_tracker_class.return_value = mock_tracker - - collisions = _detect_installed_collisions( - instructions=[mock_library_instruction], - ai_tools=[mock_tool], - install_names={mock_library_instruction.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # Should detect collision - assert len(collisions) == 1 - assert mock_library_instruction.id in collisions - assert collisions[mock_library_instruction.id] == [existing_record] - - def test_no_collision_same_repo(self, mock_library_instruction, mock_project_with_git): - """Test that no collision when same name from same repo.""" - # Existing installation from same repo - existing_record = InstallationRecord( - instruction_name="python-style", - ai_tool=AIToolType.COPILOT, - source_repo="https://github.com/test/test-repo.git", # Same repo - installed_path=".github/instructions/python-style.md", - installed_at=None, - checksum="abc123", - bundle_name=None, - scope=None, - source_ref="v1.0.0", - source_ref_type=RefType.TAG, - ) - - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.find_instructions_by_name.return_value = [existing_record] - mock_tracker_class.return_value = mock_tracker - - collisions = _detect_installed_collisions( - instructions=[mock_library_instruction], - ai_tools=[mock_tool], - install_names={mock_library_instruction.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # No collision (same repo) - assert len(collisions) == 0 - - def test_no_collision_when_not_installed(self, mock_library_instruction, mock_project_with_git): - """Test that no collision when instruction not installed.""" - mock_tool = MagicMock() - mock_tool.tool_type = AIToolType.COPILOT - - with patch("devsync.cli.install_new.InstallationTracker") as mock_tracker_class: - mock_tracker = MagicMock() - mock_tracker.find_instructions_by_name.return_value = [] # Not installed - mock_tracker_class.return_value = mock_tracker - - collisions = _detect_installed_collisions( - instructions=[mock_library_instruction], - ai_tools=[mock_tool], - install_names={mock_library_instruction.id: "python-style"}, - project_root=mock_project_with_git, - ) - - # No collisions - assert len(collisions) == 0 diff --git a/tests/unit/test_library_versioning.py b/tests/unit/test_library_versioning.py deleted file mode 100644 index 65448ac..0000000 --- a/tests/unit/test_library_versioning.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Unit tests for library versioning functions.""" - -import json -import re -from datetime import datetime - -from devsync.storage.library import LibraryManager - - -def create_test_repo_data(namespace: str, name: str, version: str = "1.0.0") -> dict: - """Helper to create minimal valid repository data for tests.""" - return { - "namespace": namespace, - "name": name, - "description": "Test repository", - "url": f"https://github.com/user/{name}", - "author": "test-author", - "version": version, - "downloaded_at": datetime.now().isoformat(), - "alias": None, - "instructions": [], - } - - -class TestGetVersionedNamespace: - """Test get_versioned_namespace function.""" - - def test_versioned_namespace_with_tag(self, tmp_path): - """Test generating versioned namespace with tag.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "v1.0.0") - - assert "@v1.0.0" in namespace - # Dots become underscores in namespaces - assert "github" in namespace - assert "user" in namespace - assert "repo" in namespace - - def test_versioned_namespace_with_branch(self, tmp_path): - """Test generating versioned namespace with branch.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "main") - - assert "@main" in namespace - assert "github" in namespace - - def test_versioned_namespace_with_commit(self, tmp_path): - """Test generating versioned namespace with commit hash.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "abc123def") - - assert "@abc123def" in namespace - assert "github" in namespace - - def test_versioned_namespace_sanitizes_special_chars(self, tmp_path): - """Test that special characters in refs are sanitized.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "feature/new-feature") - - # Slashes should be replaced with underscores - assert "@feature_new-feature" in namespace or "@feature" in namespace - - def test_versioned_namespace_different_refs_same_repo(self, tmp_path): - """Test that different refs produce different namespaces.""" - library = LibraryManager(library_dir=tmp_path) - - ns1 = library.get_versioned_namespace("https://github.com/user/repo", "v1.0.0") - ns2 = library.get_versioned_namespace("https://github.com/user/repo", "v2.0.0") - ns3 = library.get_versioned_namespace("https://github.com/user/repo", "main") - - assert ns1 != ns2 - assert ns1 != ns3 - assert ns2 != ns3 - assert "@v1.0.0" in ns1 - assert "@v2.0.0" in ns2 - assert "@main" in ns3 - - -class TestListRepositoryVersions: - """Test list_repository_versions function.""" - - def test_list_versions_no_versions(self, tmp_path): - """Test listing versions when no versions exist.""" - library = LibraryManager(library_dir=tmp_path) - - versions = library.list_repository_versions("https://github.com/user/repo") - - assert versions == [] - - def test_list_versions_single_version(self, tmp_path): - """Test listing versions with one version.""" - library = LibraryManager(library_dir=tmp_path / "library") - - # Create a mock repository with versioned namespace - namespace = library.get_versioned_namespace("https://github.com/user/repo", "v1.0.0") - # Add to index with complete data (index_file is at library_dir.parent / "library.json") - index_data = {namespace: create_test_repo_data(namespace, "test-repo", "1.0.0")} - with open(library.index_file, "w") as f: - json.dump(index_data, f) - - versions = library.list_repository_versions("https://github.com/user/repo") - - assert len(versions) == 1 - assert versions[0][0] == "v1.0.0" # version ref - assert versions[0][1] == namespace # full namespace - - def test_list_versions_multiple_versions(self, tmp_path): - """Test listing multiple versions of same repository.""" - library = LibraryManager(library_dir=tmp_path / "library") - - # Add multiple versions - ns1 = library.get_versioned_namespace("https://github.com/user/repo", "v1.0.0") - ns2 = library.get_versioned_namespace("https://github.com/user/repo", "v2.0.0") - ns3 = library.get_versioned_namespace("https://github.com/user/repo", "main") - - index_data = { - ns1: create_test_repo_data(ns1, "test-repo", "1.0.0"), - ns2: create_test_repo_data(ns2, "test-repo", "2.0.0"), - ns3: create_test_repo_data(ns3, "test-repo", "latest"), - } - with open(library.index_file, "w") as f: - json.dump(index_data, f) - - versions = library.list_repository_versions("https://github.com/user/repo") - - assert len(versions) == 3 - version_refs = [v[0] for v in versions] - assert "v1.0.0" in version_refs - assert "v2.0.0" in version_refs - assert "main" in version_refs - - def test_list_versions_ignores_other_repos(self, tmp_path): - """Test that listing only returns versions of specified repo.""" - library = LibraryManager(library_dir=tmp_path / "library") - - # Add versions for two different repos - ns1 = library.get_versioned_namespace("https://github.com/user/repo1", "v1.0.0") - ns2 = library.get_versioned_namespace("https://github.com/user/repo2", "v1.0.0") - - index_data = { - ns1: create_test_repo_data(ns1, "repo1", "1.0.0"), - ns2: create_test_repo_data(ns2, "repo2", "1.0.0"), - } - with open(library.index_file, "w") as f: - json.dump(index_data, f) - - versions1 = library.list_repository_versions("https://github.com/user/repo1") - versions2 = library.list_repository_versions("https://github.com/user/repo2") - - assert len(versions1) == 1 - assert len(versions2) == 1 - assert versions1[0][1] != versions2[0][1] # Different namespaces - - def test_list_versions_with_legacy_non_versioned(self, tmp_path): - """Test that legacy non-versioned repos are included as 'default'.""" - library = LibraryManager(library_dir=tmp_path / "library") - - # Add a non-versioned namespace (legacy format) - base_namespace = library.get_repo_namespace("https://github.com/user/repo", "test-repo") - - index_data = {base_namespace: create_test_repo_data(base_namespace, "test-repo", "1.0.0")} - with open(library.index_file, "w") as f: - json.dump(index_data, f) - - versions = library.list_repository_versions("https://github.com/user/repo") - - # Should include the default version - assert len(versions) == 1 - assert versions[0][0] == "default" - assert versions[0][1] == base_namespace - - -class TestNamespaceSanitization: - """Test that refs with special characters are properly sanitized.""" - - def test_sanitize_ref_with_slash(self, tmp_path): - """Test that slashes in branch names are sanitized.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "feature/new-ui") - - # Should not contain literal slash - assert "/" not in namespace.split("@")[1] if "@" in namespace else True - # Should contain underscore or hyphen instead - assert re.search(r"@.*[_-]", namespace) - - def test_sanitize_ref_with_special_chars(self, tmp_path): - """Test that special characters are sanitized.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "refs/tags/v1.0.0") - - # Should not contain slashes - ref_part = namespace.split("@")[1] if "@" in namespace else namespace - assert "/" not in ref_part - - def test_sanitize_preserves_alphanumeric(self, tmp_path): - """Test that alphanumeric characters and common chars are preserved.""" - library = LibraryManager(library_dir=tmp_path) - - namespace = library.get_versioned_namespace("https://github.com/user/repo", "v1.2.3-alpha.1") - - # Should preserve dots, hyphens - assert "@v1" in namespace - assert "2" in namespace - assert "3" in namespace diff --git a/tests/unit/test_template_init.py b/tests/unit/test_template_init.py deleted file mode 100644 index 0a144ae..0000000 --- a/tests/unit/test_template_init.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Tests for template init command.""" - -from unittest.mock import patch - -import pytest -import typer - -from devsync.cli.template_init import init_command - - -class TestTemplateInit: - """Tests for template init command.""" - - def test_init_basic(self, tmp_path): - """Test basic template initialization.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - # Verify directory structure (IDE-agnostic) - assert repo_path.exists() - assert (repo_path / "templatekit.yaml").exists() - assert (repo_path / "README.md").exists() - assert (repo_path / ".gitignore").exists() - assert (repo_path / "templates" / "instructions").exists() - assert (repo_path / "templates" / "commands").exists() - assert (repo_path / "templates" / "hooks").exists() - - def test_init_with_examples(self, tmp_path): - """Test that example templates are created.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - # Verify example files (IDE-agnostic paths) - assert (repo_path / "templates" / "instructions" / "example-instruction.md").exists() - assert (repo_path / "templates" / "commands" / "example-command.md").exists() - assert (repo_path / "templates" / "hooks" / "example-hook.md").exists() - - # Verify content is not empty - instruction = (repo_path / "templates" / "instructions" / "example-instruction.md").read_text(encoding="utf-8") - assert len(instruction) > 100 - assert "Example Coding Standards" in instruction - - def test_init_with_custom_namespace(self, tmp_path): - """Test initialization with custom namespace.""" - repo_name = "company-standards" - repo_path = tmp_path / repo_name - custom_namespace = "acme" - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name, namespace=custom_namespace) - - # Verify namespace in manifest - manifest = (repo_path / "templatekit.yaml").read_text(encoding="utf-8") - assert custom_namespace in manifest - - # Verify namespace in README - readme = (repo_path / "README.md").read_text(encoding="utf-8") - assert custom_namespace in readme - - def test_init_with_description(self, tmp_path): - """Test initialization with custom description.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - description = "ACME Corp Engineering Standards" - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name, description=description) - - # Verify description in manifest - manifest = (repo_path / "templatekit.yaml").read_text(encoding="utf-8") - assert description in manifest - - # Verify description in README - readme = (repo_path / "README.md").read_text(encoding="utf-8") - assert description in readme - - def test_init_with_author(self, tmp_path): - """Test initialization with custom author.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - author = "Jane Doe" - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name, author=author) - - # Verify author in manifest - manifest = (repo_path / "templatekit.yaml").read_text(encoding="utf-8") - assert author in manifest - - def test_init_existing_directory_no_force(self, tmp_path): - """Test that init fails when directory exists without --force.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - repo_path.mkdir() - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - with pytest.raises(typer.Exit) as exc_info: - init_command(directory=repo_name, force=False) - - assert exc_info.value.exit_code == 1 - - def test_init_existing_directory_with_force(self, tmp_path): - """Test that init overwrites when --force is used.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - repo_path.mkdir() - (repo_path / "existing.txt").write_text("old content") - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name, force=True) - - # Verify new files were created (IDE-agnostic paths) - assert (repo_path / "templatekit.yaml").exists() - assert (repo_path / "templates" / "instructions" / "example-instruction.md").exists() - - def test_init_manifest_structure(self, tmp_path): - """Test that templatekit.yaml has correct structure.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - manifest_content = (repo_path / "templatekit.yaml").read_text(encoding="utf-8") - - # Verify required fields - assert "name:" in manifest_content - assert "description:" in manifest_content - assert "version: 1.0.0" in manifest_content - assert "author:" in manifest_content - assert "templates:" in manifest_content - - # Verify example templates - assert "example-instruction" in manifest_content - assert "example-command" in manifest_content - assert "example-hook" in manifest_content - - # Verify bundles section - assert "bundles:" in manifest_content - assert "getting-started" in manifest_content - - def test_init_readme_structure(self, tmp_path): - """Test that README.md has proper documentation.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - readme_content = (repo_path / "README.md").read_text(encoding="utf-8") - - # Verify essential sections - assert "Installation" in readme_content or "installation" in readme_content.lower() - assert "Usage" in readme_content or "usage" in readme_content.lower() - assert "Customization" in readme_content or "customization" in readme_content.lower() - assert "DevSync" in readme_content - - # Verify example commands - assert "devsync template install" in readme_content - assert "devsync template list" in readme_content - - def test_init_gitignore_content(self, tmp_path): - """Test that .gitignore has appropriate entries.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - gitignore_content = (repo_path / ".gitignore").read_text(encoding="utf-8") - - # Verify important ignores - assert ".devsync/" in gitignore_content - assert "__pycache__/" in gitignore_content - assert ".vscode/" in gitignore_content or ".idea/" in gitignore_content - - def test_init_namespace_sanitization(self, tmp_path): - """Test that namespace is sanitized from directory name.""" - repo_name = "my-awesome-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - readme = (repo_path / "README.md").read_text(encoding="utf-8") - - # Namespace should have underscores instead of hyphens - assert "my_awesome_templates" in readme - - def test_init_all_options(self, tmp_path): - """Test initialization with all options specified.""" - repo_name = "company-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command( - directory=repo_name, - namespace="acme", - description="ACME Engineering Standards", - author="ACME Engineering Team", - force=False, - ) - - # Verify all customizations applied - manifest = (repo_path / "templatekit.yaml").read_text(encoding="utf-8") - assert "ACME Engineering Standards" in manifest - assert "ACME Engineering Team" in manifest - - # Verify namespace in README (namespace is used in install examples, not in manifest) - readme = (repo_path / "README.md").read_text(encoding="utf-8") - assert "acme" in readme - - def test_init_file_content_quality(self, tmp_path): - """Test that generated files have helpful content.""" - repo_name = "my-templates" - repo_path = tmp_path / repo_name - - with patch("devsync.cli.template_init.Path.resolve", return_value=repo_path): - init_command(directory=repo_name) - - # Check instruction file has guidance (IDE-agnostic paths) - instruction = (repo_path / "templates" / "instructions" / "example-instruction.md").read_text(encoding="utf-8") - assert "Purpose" in instruction - assert "Customization" in instruction - assert len(instruction) > 500 # Should be substantial - - # Check command file has guidance - command = (repo_path / "templates" / "commands" / "example-command.md").read_text(encoding="utf-8") - assert "Purpose" in command - assert "Example" in command or "example" in command.lower() - assert len(command) > 500 - - # Check hook file has guidance - hook = (repo_path / "templates" / "hooks" / "example-hook.md").read_text(encoding="utf-8") - assert "Purpose" in hook - assert "Hook Types" in hook or "hook" in hook.lower() - assert len(hook) > 500 - - @patch("devsync.cli.template_init.Path.mkdir") - def test_init_exception_handling(self, mock_mkdir, tmp_path): - """Test exception handling during init.""" - repo_name = "my-templates" - mock_mkdir.side_effect = RuntimeError("Permission denied") - - with pytest.raises(typer.Exit) as exc_info: - init_command(directory=repo_name) - - assert exc_info.value.exit_code == 1 diff --git a/tests/unit/test_template_library.py b/tests/unit/test_template_library.py deleted file mode 100644 index 10e1a7e..0000000 --- a/tests/unit/test_template_library.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Tests for template library management.""" - -from unittest.mock import patch - -import pytest - -from devsync.core.models import TemplateDefinition, TemplateFile, TemplateManifest -from devsync.storage.template_library import TemplateLibraryManager - - -@pytest.fixture -def temp_library(tmp_path): - """Create a temporary library directory.""" - library_path = tmp_path / "library" - library_path.mkdir() - return library_path - - -@pytest.fixture -def sample_manifest(): - """Create a sample template manifest.""" - return TemplateManifest( - name="Test Templates", - description="Test template repository", - version="1.0.0", - author=None, - templates=[ - TemplateDefinition( - name="test-command", - description="Test command template", - files=[TemplateFile(path=".claude/commands/test.md", ide="claude")], - tags=["test"], - dependencies=[], - ), - TemplateDefinition( - name="python-standards", - description="Python coding standards", - files=[TemplateFile(path=".claude/rules/python.md", ide="claude")], - tags=["python", "standards"], - dependencies=[], - ), - ], - bundles=[], - ) - - -@pytest.fixture -def mock_repo_structure(tmp_path): - """Create a mock repository structure with manifest.""" - repo_path = tmp_path / "acme-templates" - repo_path.mkdir() - - # Create manifest - manifest_content = """name: ACME Templates -description: ACME template repository -version: 1.0.0 - -templates: - - name: test-command - description: Test command - files: - - path: .claude/commands/test.md - ide: claude - tags: [test] - - - name: python-standards - description: Python standards - files: - - path: .claude/rules/python.md - ide: claude - tags: [python, standards] -""" - (repo_path / "templatekit.yaml").write_text(manifest_content) - - # Create template files - (repo_path / ".claude" / "commands").mkdir(parents=True) - (repo_path / ".claude" / "rules").mkdir(parents=True) - (repo_path / ".claude" / "commands" / "test.md").write_text("# Test Command") - (repo_path / ".claude" / "rules" / "python.md").write_text("# Python Standards") - - return repo_path - - -class TestTemplateLibraryManagerInit: - """Tests for TemplateLibraryManager initialization.""" - - def test_default_library_path(self, tmp_path, monkeypatch): - """Test initialization with default library path.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - manager = TemplateLibraryManager() - expected_path = tmp_path / ".devsync" / "templates" - assert manager.library_path == expected_path - - def test_custom_library_path(self, temp_library): - """Test initialization with custom library path.""" - manager = TemplateLibraryManager(library_path=temp_library) - assert manager.library_path == temp_library - - def test_creates_library_directory(self, tmp_path): - """Test that library directory is created if it doesn't exist.""" - library_path = tmp_path / "new_library" - assert not library_path.exists() - - TemplateLibraryManager(library_path=library_path) - assert library_path.exists() - assert library_path.is_dir() - - -class TestCloneRepository: - """Tests for clone_repository method.""" - - @patch("devsync.storage.template_library.clone_template_repo") - @patch("devsync.storage.template_library.derive_namespace") - @patch("devsync.storage.template_library.load_manifest") - def test_clone_new_repository( - self, mock_load_manifest, mock_derive_namespace, mock_clone, temp_library, sample_manifest - ): - """Test cloning a new repository.""" - mock_derive_namespace.return_value = "acme-templates" - mock_load_manifest.return_value = sample_manifest - - manager = TemplateLibraryManager(library_path=temp_library) - repo_url = "https://github.com/acme/templates" - - repo_path, manifest = manager.clone_repository(repo_url) - - # Verify namespace derivation - mock_derive_namespace.assert_called_once_with(repo_url, None) - - # Verify clone was called - expected_dest = temp_library / "acme-templates" - mock_clone.assert_called_once_with(repo_url, expected_dest) - - # Verify manifest loaded - mock_load_manifest.assert_called_once_with(expected_dest / "templatekit.yaml") - - # Verify return values - assert repo_path == expected_dest - assert manifest == sample_manifest - - @patch("devsync.storage.template_library.clone_template_repo") - @patch("devsync.storage.template_library.derive_namespace") - @patch("devsync.storage.template_library.load_manifest") - def test_clone_with_namespace_override( - self, mock_load_manifest, mock_derive_namespace, mock_clone, temp_library, sample_manifest - ): - """Test cloning with namespace override.""" - mock_derive_namespace.return_value = "custom-namespace" - mock_load_manifest.return_value = sample_manifest - - manager = TemplateLibraryManager(library_path=temp_library) - repo_url = "https://github.com/acme/templates" - - manager.clone_repository(repo_url, namespace_override="custom-namespace") - - mock_derive_namespace.assert_called_once_with(repo_url, "custom-namespace") - - @patch("devsync.storage.template_library.clone_template_repo") - @patch("devsync.storage.template_library.derive_namespace") - @patch("devsync.storage.template_library.load_manifest") - def test_clone_replaces_existing_repository( - self, mock_load_manifest, mock_derive_namespace, mock_clone, temp_library, sample_manifest - ): - """Test that cloning removes existing repository directory.""" - mock_derive_namespace.return_value = "acme-templates" - mock_load_manifest.return_value = sample_manifest - - # Create existing directory with a file - existing_repo = temp_library / "acme-templates" - existing_repo.mkdir() - (existing_repo / "old_file.txt").write_text("old content") - - manager = TemplateLibraryManager(library_path=temp_library) - manager.clone_repository("https://github.com/acme/templates") - - # Verify old directory was removed (shutil.rmtree was called) - # The mock clone should be called with the path - expected_dest = temp_library / "acme-templates" - mock_clone.assert_called_once_with("https://github.com/acme/templates", expected_dest) - - -class TestGetTemplateRepository: - """Tests for get_template_repository method.""" - - def test_get_existing_repository(self, temp_library, mock_repo_structure): - """Test getting an existing repository.""" - # Move mock repo to library - import shutil - - shutil.move(str(mock_repo_structure), str(temp_library / "acme-templates")) - - manager = TemplateLibraryManager(library_path=temp_library) - repo_path, manifest = manager.get_template_repository("acme-templates") - - assert repo_path == temp_library / "acme-templates" - assert manifest.name == "ACME Templates" - assert len(manifest.templates) == 2 - - def test_repository_not_found(self, temp_library): - """Test getting a non-existent repository raises FileNotFoundError.""" - manager = TemplateLibraryManager(library_path=temp_library) - - with pytest.raises(FileNotFoundError, match="Template repository 'nonexistent' not found"): - manager.get_template_repository("nonexistent") - - -class TestListAvailableTemplates: - """Tests for list_available_templates method.""" - - def test_list_templates(self, temp_library, mock_repo_structure): - """Test listing templates in a repository.""" - import shutil - - shutil.move(str(mock_repo_structure), str(temp_library / "acme-templates")) - - manager = TemplateLibraryManager(library_path=temp_library) - templates = manager.list_available_templates("acme-templates") - - assert len(templates) == 2 - assert "test-command" in templates - assert "python-standards" in templates - - def test_list_templates_repository_not_found(self, temp_library): - """Test listing templates for non-existent repository.""" - manager = TemplateLibraryManager(library_path=temp_library) - - with pytest.raises(FileNotFoundError): - manager.list_available_templates("nonexistent") - - -class TestGetRepositoryVersion: - """Tests for get_repository_version method.""" - - @patch("devsync.storage.template_library.get_repo_version") - def test_get_version_success(self, mock_get_version, temp_library): - """Test getting repository version successfully.""" - # Create a repository directory - repo_path = temp_library / "acme-templates" - repo_path.mkdir() - - mock_get_version.return_value = "v1.2.0" - - manager = TemplateLibraryManager(library_path=temp_library) - version = manager.get_repository_version("acme-templates") - - assert version == "v1.2.0" - mock_get_version.assert_called_once_with(repo_path) - - def test_get_version_repository_not_found(self, temp_library): - """Test getting version for non-existent repository returns None.""" - manager = TemplateLibraryManager(library_path=temp_library) - version = manager.get_repository_version("nonexistent") - - assert version is None - - @patch("devsync.storage.template_library.get_repo_version") - def test_get_version_exception_returns_none(self, mock_get_version, temp_library): - """Test that exceptions during version retrieval return None.""" - repo_path = temp_library / "acme-templates" - repo_path.mkdir() - - mock_get_version.side_effect = Exception("Git error") - - manager = TemplateLibraryManager(library_path=temp_library) - version = manager.get_repository_version("acme-templates") - - assert version is None - - -class TestListInstalledRepositories: - """Tests for list_installed_repositories method.""" - - def test_list_empty_library(self, temp_library): - """Test listing repositories in empty library.""" - manager = TemplateLibraryManager(library_path=temp_library) - repos = manager.list_installed_repositories() - - assert repos == [] - - def test_list_repositories(self, temp_library, mock_repo_structure): - """Test listing installed repositories.""" - import shutil - - # Add multiple repositories - shutil.move(str(mock_repo_structure), str(temp_library / "acme-templates")) - - # Create another repo - second_repo = temp_library / "company-standards" - second_repo.mkdir() - (second_repo / "templatekit.yaml").write_text( - """name: Company Standards -description: Company coding standards -version: 1.0.0 -templates: [] -""" - ) - - manager = TemplateLibraryManager(library_path=temp_library) - repos = manager.list_installed_repositories() - - assert len(repos) == 2 - assert "acme-templates" in repos - assert "company-standards" in repos - assert repos == sorted(repos) # Should be sorted - - def test_list_repositories_ignores_directories_without_manifest(self, temp_library): - """Test that directories without manifest are ignored.""" - # Create directory without manifest - (temp_library / "not-a-repo").mkdir() - (temp_library / "not-a-repo" / "random.txt").write_text("content") - - # Create valid repo - valid_repo = temp_library / "valid-repo" - valid_repo.mkdir() - (valid_repo / "templatekit.yaml").write_text( - """name: Valid -description: Valid repo -version: 1.0.0 -templates: [] -""" - ) - - manager = TemplateLibraryManager(library_path=temp_library) - repos = manager.list_installed_repositories() - - assert len(repos) == 1 - assert "valid-repo" in repos - assert "not-a-repo" not in repos - - def test_list_repositories_nonexistent_library(self, tmp_path): - """Test listing repositories when library doesn't exist.""" - library_path = tmp_path / "nonexistent" - manager = TemplateLibraryManager(library_path=library_path) - - repos = manager.list_installed_repositories() - assert repos == [] - - def test_list_repositories_deleted_after_init(self, tmp_path): - """Test listing repositories when library is deleted after initialization.""" - import shutil - - library_path = tmp_path / "library" - manager = TemplateLibraryManager(library_path=library_path) - - # Verify library was created by __init__ - assert library_path.exists() - - # Delete the library directory - shutil.rmtree(library_path) - - # Should return empty list when library doesn't exist - repos = manager.list_installed_repositories() - assert repos == [] - - -class TestRemoveRepository: - """Tests for remove_repository method.""" - - def test_remove_existing_repository(self, temp_library, mock_repo_structure): - """Test removing an existing repository.""" - import shutil - - repo_path = temp_library / "acme-templates" - shutil.move(str(mock_repo_structure), str(repo_path)) - - assert repo_path.exists() - - manager = TemplateLibraryManager(library_path=temp_library) - manager.remove_repository("acme-templates") - - assert not repo_path.exists() - - def test_remove_nonexistent_repository(self, temp_library): - """Test removing a non-existent repository raises FileNotFoundError.""" - manager = TemplateLibraryManager(library_path=temp_library) - - with pytest.raises(FileNotFoundError, match="Repository 'nonexistent' not found"): - manager.remove_repository("nonexistent") - - -class TestGetTemplateFilePath: - """Tests for get_template_file_path method.""" - - def test_get_existing_file_path(self, temp_library, mock_repo_structure): - """Test getting path to existing template file.""" - import shutil - - shutil.move(str(mock_repo_structure), str(temp_library / "acme-templates")) - - manager = TemplateLibraryManager(library_path=temp_library) - file_path = manager.get_template_file_path("acme-templates", "test-command", ".claude/commands/test.md") - - expected_path = temp_library / "acme-templates" / ".claude" / "commands" / "test.md" - assert file_path == expected_path - assert file_path.exists() - - def test_get_nonexistent_file_path(self, temp_library, mock_repo_structure): - """Test getting path to non-existent file raises FileNotFoundError.""" - import shutil - - shutil.move(str(mock_repo_structure), str(temp_library / "acme-templates")) - - manager = TemplateLibraryManager(library_path=temp_library) - - with pytest.raises(FileNotFoundError, match="Template file not found"): - manager.get_template_file_path("acme-templates", "test-command", "nonexistent.md") - - def test_get_file_path_repository_not_found(self, temp_library): - """Test getting file path for non-existent repository.""" - manager = TemplateLibraryManager(library_path=temp_library) - - with pytest.raises(FileNotFoundError, match="Template repository"): - manager.get_template_file_path("nonexistent", "test", "file.md") diff --git a/tests/unit/test_template_manifest.py b/tests/unit/test_template_manifest.py deleted file mode 100644 index cfcc1df..0000000 --- a/tests/unit/test_template_manifest.py +++ /dev/null @@ -1,552 +0,0 @@ -"""Tests for template manifest parsing and validation.""" - -import pytest - -from devsync.core.models import TemplateDefinition -from devsync.core.template_manifest import ( - TemplateManifestError, - load_manifest, - parse_bundle, - parse_manifest, - parse_template, - validate_dependencies, - validate_manifest_size, -) - - -@pytest.fixture -def temp_repo(tmp_path): - """Create a temporary repository structure.""" - repo_path = tmp_path / "test-repo" - repo_path.mkdir() - - # Create template files - (repo_path / ".claude").mkdir(parents=True) - (repo_path / ".claude" / "rules").mkdir() - (repo_path / ".claude" / "commands").mkdir() - - (repo_path / ".claude" / "rules" / "python-standards.md").write_text("# Python Standards") - (repo_path / ".claude" / "commands" / "test-api.md").write_text("# Test API") - - return repo_path - - -@pytest.fixture -def minimal_manifest(temp_repo): - """Create minimal valid manifest.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text( - """ -name: Test Templates -description: Test template repository -version: 1.0.0 -templates: - - name: python-standards - description: Python coding standards - files: - - path: .claude/rules/python-standards.md -""" - ) - return manifest_path - - -class TestLoadManifest: - """Tests for load_manifest function.""" - - def test_load_valid_manifest(self, minimal_manifest): - """Test loading a valid manifest.""" - manifest = load_manifest(minimal_manifest) - assert manifest.name == "Test Templates" - assert manifest.version == "1.0.0" - assert len(manifest.templates) == 1 - - def test_missing_file_raises(self, tmp_path): - """Test that missing manifest file raises FileNotFoundError.""" - with pytest.raises(FileNotFoundError): - load_manifest(tmp_path / "nonexistent.yaml") - - def test_empty_file_raises(self, tmp_path): - """Test that empty manifest file raises error.""" - manifest_path = tmp_path / "templatekit.yaml" - manifest_path.write_text("") - - with pytest.raises(TemplateManifestError, match="empty"): - load_manifest(manifest_path) - - def test_invalid_yaml_raises(self, tmp_path): - """Test that invalid YAML raises error.""" - manifest_path = tmp_path / "templatekit.yaml" - manifest_path.write_text("name: Test\n invalid: yaml: syntax") - - with pytest.raises(TemplateManifestError, match="Invalid YAML"): - load_manifest(manifest_path) - - def test_manifest_with_author(self, temp_repo): - """Test manifest with optional author field.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text( - """ -name: Test Templates -description: Test repository -version: 1.0.0 -author: Test Author -templates: - - name: python-standards - description: Python standards - files: - - path: .claude/rules/python-standards.md -""" - ) - manifest = load_manifest(manifest_path) - assert manifest.author == "Test Author" - - def test_manifest_with_bundles(self, temp_repo): - """Test manifest with bundles.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text( - """ -name: Test Templates -description: Test repository -version: 1.0.0 -templates: - - name: python-standards - description: Python standards - files: - - path: .claude/rules/python-standards.md - - name: test-api - description: Test API command - files: - - path: .claude/commands/test-api.md -bundles: - - name: python-stack - description: Complete Python setup - templates: - - python-standards - - test-api - tags: [python] -""" - ) - manifest = load_manifest(manifest_path) - assert len(manifest.bundles) == 1 - assert manifest.bundles[0].name == "python-stack" - assert len(manifest.bundles[0].template_refs) == 2 - - def test_manifest_with_invalid_structure_raises(self, temp_repo): - """Test that invalid manifest structure raises TemplateManifestError.""" - manifest_path = temp_repo / "templatekit.yaml" - # Create a manifest that will cause ValueError in parse_manifest - manifest_path.write_text( - """ -name: 123 -description: Test -version: 1.0.0 -templates: not_a_list -""" - ) - - with pytest.raises(TemplateManifestError, match="Invalid manifest structure"): - load_manifest(manifest_path) - - -class TestParseManifest: - """Tests for parse_manifest function.""" - - def test_missing_name_raises(self, temp_repo): - """Test that missing name field raises error.""" - data = {"description": "Test", "version": "1.0.0", "templates": []} - with pytest.raises(ValueError, match="name"): - parse_manifest(data, temp_repo / "templatekit.yaml") - - def test_missing_description_raises(self, temp_repo): - """Test that missing description field raises error.""" - data = {"name": "Test", "version": "1.0.0", "templates": []} - with pytest.raises(ValueError, match="description"): - parse_manifest(data, temp_repo / "templatekit.yaml") - - def test_missing_version_raises(self, temp_repo): - """Test that missing version field raises error.""" - data = {"name": "Test", "description": "Test", "templates": []} - with pytest.raises(ValueError, match="version"): - parse_manifest(data, temp_repo / "templatekit.yaml") - - def test_missing_templates_raises(self, temp_repo): - """Test that missing templates field raises error.""" - data = {"name": "Test", "description": "Test", "version": "1.0.0"} - with pytest.raises(ValueError, match="templates"): - parse_manifest(data, temp_repo / "templatekit.yaml") - - def test_bundle_with_invalid_template_reference(self, temp_repo): - """Test that bundle referencing non-existent template raises error.""" - data = { - "name": "Test", - "description": "Test", - "version": "1.0.0", - "templates": [ - { - "name": "template1", - "description": "Test", - "files": [{"path": ".claude/rules/python-standards.md"}], - } - ], - "bundles": [ - { - "name": "bundle1", - "description": "Test bundle", - "templates": ["template1", "nonexistent"], - } - ], - } - - with pytest.raises(ValueError, match="non-existent template"): - parse_manifest(data, temp_repo / "templatekit.yaml") - - -class TestParseTemplate: - """Tests for parse_template function.""" - - def test_parse_minimal_template(self, temp_repo): - """Test parsing minimal template definition.""" - data = { - "name": "python-standards", - "description": "Python coding standards", - "files": [{"path": ".claude/rules/python-standards.md"}], - } - - template = parse_template(data, temp_repo / "templatekit.yaml") - assert template.name == "python-standards" - assert template.description == "Python coding standards" - assert len(template.files) == 1 - assert template.tags == [] - assert template.dependencies == [] - - def test_parse_template_with_tags(self, temp_repo): - """Test parsing template with tags.""" - data = { - "name": "python-standards", - "description": "Python standards", - "files": [{"path": ".claude/rules/python-standards.md"}], - "tags": ["python", "standards"], - } - - template = parse_template(data, temp_repo / "templatekit.yaml") - assert template.tags == ["python", "standards"] - - def test_parse_template_with_dependencies(self, temp_repo): - """Test parsing template with dependencies.""" - data = { - "name": "python-standards", - "description": "Python standards", - "files": [{"path": ".claude/rules/python-standards.md"}], - "dependencies": ["base-standards"], - } - - template = parse_template(data, temp_repo / "templatekit.yaml") - assert template.dependencies == ["base-standards"] - - def test_missing_name_raises(self, temp_repo): - """Test that template missing name raises error.""" - data = { - "description": "Test", - "files": [{"path": ".claude/rules/python-standards.md"}], - } - - with pytest.raises(ValueError, match="name"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_missing_description_raises(self, temp_repo): - """Test that template missing description raises error.""" - data = { - "name": "test", - "files": [{"path": ".claude/rules/python-standards.md"}], - } - - with pytest.raises(ValueError, match="description"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_missing_files_raises(self, temp_repo): - """Test that template missing files raises error.""" - data = {"name": "test", "description": "Test"} - - with pytest.raises(ValueError, match="at least one file"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_empty_files_list_raises(self, temp_repo): - """Test that template with empty files list raises error.""" - data = {"name": "test", "description": "Test", "files": []} - - with pytest.raises(ValueError, match="at least one file"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_nonexistent_file_raises(self, temp_repo): - """Test that reference to non-existent file raises error.""" - data = { - "name": "test", - "description": "Test", - "files": [{"path": ".claude/rules/nonexistent.md"}], - } - - with pytest.raises(ValueError, match="non-existent file"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_simple_file_format(self, temp_repo): - """Test parsing template with simple file format (just path string).""" - data = { - "name": "python-standards", - "description": "Python standards", - "files": [".claude/rules/python-standards.md"], - } - - template = parse_template(data, temp_repo / "templatekit.yaml") - assert len(template.files) == 1 - assert template.files[0].path == ".claude/rules/python-standards.md" - assert template.files[0].ide == "all" - - def test_detailed_file_format(self, temp_repo): - """Test parsing template with detailed file format.""" - data = { - "name": "python-standards", - "description": "Python standards", - "files": [{"path": ".claude/rules/python-standards.md", "ide": "claude"}], - } - - template = parse_template(data, temp_repo / "templatekit.yaml") - assert template.files[0].ide == "claude" - - def test_file_without_path_raises(self, temp_repo): - """Test that file entry without path raises error.""" - data = { - "name": "test", - "description": "Test", - "files": [{"ide": "claude"}], - } - - with pytest.raises(ValueError, match="missing 'path'"): - parse_template(data, temp_repo / "templatekit.yaml") - - def test_invalid_file_entry_type_raises(self, temp_repo): - """Test that invalid file entry type raises error.""" - data = { - "name": "test", - "description": "Test", - "files": [123], # Invalid: not string or dict - } - - with pytest.raises(ValueError, match="Invalid file entry"): - parse_template(data, temp_repo / "templatekit.yaml") - - -class TestParseBundle: - """Tests for parse_bundle function.""" - - def test_parse_minimal_bundle(self): - """Test parsing minimal bundle definition.""" - data = { - "name": "python-stack", - "description": "Complete Python setup", - "templates": ["python-standards", "test-api"], - } - - bundle = parse_bundle(data) - assert bundle.name == "python-stack" - assert bundle.description == "Complete Python setup" - assert bundle.template_refs == ["python-standards", "test-api"] - assert bundle.tags == [] - - def test_parse_bundle_with_tags(self): - """Test parsing bundle with tags.""" - data = { - "name": "python-stack", - "description": "Complete Python setup", - "templates": ["python-standards", "test-api"], - "tags": ["python", "backend"], - } - - bundle = parse_bundle(data) - assert bundle.tags == ["python", "backend"] - - def test_missing_name_raises(self): - """Test that bundle missing name raises error.""" - data = { - "description": "Test", - "templates": ["template1"], - } - - with pytest.raises(ValueError, match="name"): - parse_bundle(data) - - def test_missing_description_raises(self): - """Test that bundle missing description raises error.""" - data = { - "name": "test", - "templates": ["template1"], - } - - with pytest.raises(ValueError, match="description"): - parse_bundle(data) - - def test_missing_templates_raises(self): - """Test that bundle missing templates field raises error.""" - data = { - "name": "test", - "description": "Test", - } - - with pytest.raises(ValueError, match="at least one template"): - parse_bundle(data) - - def test_empty_templates_list_raises(self): - """Test that bundle with empty templates list raises error.""" - data = { - "name": "test", - "description": "Test", - "templates": [], - } - - with pytest.raises(ValueError, match="at least one template"): - parse_bundle(data) - - -class TestValidateManifestSize: - """Tests for validate_manifest_size function.""" - - def test_small_manifest_no_warnings(self, temp_repo): - """Test that small manifest produces no warnings.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text("test: content") - - warnings = validate_manifest_size(manifest_path, 50) - assert len(warnings) == 0 - - def test_large_template_count_warning(self, temp_repo): - """Test that large template count produces warning.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text("test: content") - - warnings = validate_manifest_size(manifest_path, 150) - assert len(warnings) > 0 - assert any("150 templates" in w for w in warnings) - - def test_large_repository_size_warning(self, temp_repo): - """Test that large repository size produces warning.""" - manifest_path = temp_repo / "templatekit.yaml" - - # Create large file (>50MB worth of content) - large_file = temp_repo / "large.dat" - large_file.write_bytes(b"x" * (51 * 1024 * 1024)) - - warnings = validate_manifest_size(manifest_path, 10) - assert len(warnings) > 0 - assert any("MB" in w for w in warnings) - - def test_custom_soft_limit(self, temp_repo): - """Test using custom soft limit for template count.""" - manifest_path = temp_repo / "templatekit.yaml" - manifest_path.write_text("test: content") - - warnings = validate_manifest_size(manifest_path, 60, soft_limit_templates=50) - assert len(warnings) > 0 - - -class TestValidateDependencies: - """Tests for validate_dependencies function.""" - - @pytest.fixture - def dummy_file(self): - """Create a dummy template file for testing.""" - from devsync.core.models import TemplateFile - - return [TemplateFile(path="test.md", ide="all")] - - def test_no_dependencies_valid(self, dummy_file): - """Test that templates without dependencies are valid.""" - templates = [ - TemplateDefinition(name="template1", description="Test 1", files=dummy_file, tags=[], dependencies=[]), - TemplateDefinition(name="template2", description="Test 2", files=dummy_file, tags=[], dependencies=[]), - ] - - errors = validate_dependencies(templates) - assert len(errors) == 0 - - def test_valid_dependencies(self, dummy_file): - """Test that valid dependencies pass validation.""" - templates = [ - TemplateDefinition(name="base", description="Base", files=dummy_file, tags=[], dependencies=[]), - TemplateDefinition( - name="extended", description="Extended", files=dummy_file, tags=[], dependencies=["base"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) == 0 - - def test_circular_dependency_detected(self, dummy_file): - """Test that circular dependencies are detected.""" - templates = [ - TemplateDefinition( - name="template1", description="Test 1", files=dummy_file, tags=[], dependencies=["template2"] - ), - TemplateDefinition( - name="template2", description="Test 2", files=dummy_file, tags=[], dependencies=["template1"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) > 0 - assert any("Circular dependency" in e for e in errors) - - def test_self_circular_dependency(self, dummy_file): - """Test that self-referencing template is detected.""" - templates = [ - TemplateDefinition( - name="template1", description="Test", files=dummy_file, tags=[], dependencies=["template1"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) > 0 - - def test_nonexistent_dependency_detected(self, dummy_file): - """Test that non-existent dependency is detected.""" - templates = [ - TemplateDefinition( - name="template1", description="Test", files=dummy_file, tags=[], dependencies=["nonexistent"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) > 0 - assert any("non-existent template" in e for e in errors) - - def test_complex_dependency_chain(self, dummy_file): - """Test complex but valid dependency chain.""" - templates = [ - TemplateDefinition(name="base", description="Base", files=dummy_file, tags=[], dependencies=[]), - TemplateDefinition(name="layer1", description="Layer 1", files=dummy_file, tags=[], dependencies=["base"]), - TemplateDefinition( - name="layer2", description="Layer 2", files=dummy_file, tags=[], dependencies=["layer1"] - ), - TemplateDefinition( - name="layer3", description="Layer 3", files=dummy_file, tags=[], dependencies=["layer2"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) == 0 - - def test_indirect_circular_dependency(self, dummy_file): - """Test that indirect circular dependency is detected.""" - templates = [ - TemplateDefinition( - name="template1", description="Test 1", files=dummy_file, tags=[], dependencies=["template2"] - ), - TemplateDefinition( - name="template2", description="Test 2", files=dummy_file, tags=[], dependencies=["template3"] - ), - TemplateDefinition( - name="template3", description="Test 3", files=dummy_file, tags=[], dependencies=["template1"] - ), - ] - - errors = validate_dependencies(templates) - assert len(errors) > 0 - assert any("Circular dependency" in e for e in errors) diff --git a/tests/unit/test_template_tracker.py b/tests/unit/test_template_tracker.py deleted file mode 100644 index 9bc5ef7..0000000 --- a/tests/unit/test_template_tracker.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Tests for template installation tracking.""" - -import json -from datetime import datetime - -import pytest - -from devsync.core.models import AIToolType, InstallationScope, TemplateInstallationRecord -from devsync.storage.template_tracker import TemplateInstallationTracker - - -@pytest.fixture -def sample_record(): - """Create a sample installation record.""" - return TemplateInstallationRecord( - id="550e8400-e29b-41d4-a716-446655440000", - template_name="test-command", - source_repo="acme-templates", - source_version="1.0.0", - namespace="acme", - installed_path="/project/.claude/commands/acme.test-command.md", - scope=InstallationScope.PROJECT, - installed_at=datetime(2024, 1, 15, 10, 30, 0), - checksum="a" * 64, - ide_type=AIToolType.CLAUDE, - ) - - -@pytest.fixture -def sample_record_2(): - """Create a second sample installation record.""" - return TemplateInstallationRecord( - id="660f9511-f3ac-52e5-b827-557766551111", - template_name="python-standards", - source_repo="acme-templates", - source_version="1.0.0", - namespace="acme", - installed_path="/project/.claude/rules/acme.python-standards.md", - scope=InstallationScope.PROJECT, - installed_at=datetime(2024, 1, 15, 11, 0, 0), - checksum="b" * 64, - ide_type=AIToolType.CLAUDE, - ) - - -@pytest.fixture -def different_repo_record(): - """Create a record from a different repository.""" - return TemplateInstallationRecord( - id="770f9622-f4bd-63f6-c938-668877662222", - template_name="api-docs", - source_repo="company-templates", - source_version="2.0.0", - namespace="company", - installed_path="/project/.cursor/rules/company.api-docs.mdc", - scope=InstallationScope.PROJECT, - installed_at=datetime(2024, 1, 16, 9, 0, 0), - checksum="c" * 64, - ide_type=AIToolType.CURSOR, - ) - - -class TestTemplateInstallationTrackerInit: - """Tests for TemplateInstallationTracker initialization.""" - - def test_init_with_tracking_file(self, tmp_path): - """Test initialization with tracking file path.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - assert tracker.tracking_file == tracking_file - - def test_for_project(self, tmp_path): - """Test creating tracker for project.""" - project_root = tmp_path / "project" - project_root.mkdir() - - tracker = TemplateInstallationTracker.for_project(project_root) - - expected_file = project_root / ".devsync" / "template-installations.json" - assert tracker.tracking_file == expected_file - assert expected_file.parent.exists() - - def test_for_global(self, tmp_path, monkeypatch): - """Test creating tracker for global installations.""" - monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) - tracker = TemplateInstallationTracker.for_global() - - expected_file = tmp_path / ".devsync" / "global-template-installations.json" - assert tracker.tracking_file == expected_file - - -class TestLoadInstallationRecords: - """Tests for load_installation_records method.""" - - def test_load_empty_returns_empty_list(self, tmp_path): - """Test loading when file doesn't exist returns empty list.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - records = tracker.load_installation_records() - - assert records == [] - - def test_load_valid_records(self, tmp_path, sample_record, sample_record_2): - """Test loading valid installation records.""" - tracking_file = tmp_path / "installations.json" - - # Create valid JSON file - data = { - "installations": [sample_record.to_dict(), sample_record_2.to_dict()], - "last_updated": "2024-01-15T12:00:00", - "schema_version": "1.0", - } - - tracking_file.write_text(json.dumps(data, indent=2)) - - tracker = TemplateInstallationTracker(tracking_file) - records = tracker.load_installation_records() - - assert len(records) == 2 - assert records[0].id == sample_record.id - assert records[0].template_name == "test-command" - assert records[1].id == sample_record_2.id - assert records[1].template_name == "python-standards" - - def test_load_invalid_json_returns_empty(self, tmp_path, capsys): - """Test loading invalid JSON returns empty list with warning.""" - tracking_file = tmp_path / "installations.json" - tracking_file.write_text("not valid json{") - - tracker = TemplateInstallationTracker(tracking_file) - records = tracker.load_installation_records() - - assert records == [] - - # Check warning was printed - captured = capsys.readouterr() - assert "Warning: Failed to load installation records" in captured.out - - def test_load_skips_invalid_records(self, tmp_path, sample_record, capsys): - """Test that invalid records are skipped with warning.""" - tracking_file = tmp_path / "installations.json" - - # Create data with one valid and one invalid record - data = { - "installations": [ - sample_record.to_dict(), - {"invalid": "record", "missing": "required_fields"}, - ], - "last_updated": "2024-01-15T12:00:00", - "schema_version": "1.0", - } - - tracking_file.write_text(json.dumps(data, indent=2)) - - tracker = TemplateInstallationTracker(tracking_file) - records = tracker.load_installation_records() - - assert len(records) == 1 - assert records[0].id == sample_record.id - - # Check warning was printed - captured = capsys.readouterr() - assert "Warning: Skipping invalid installation record" in captured.out - - -class TestSaveInstallationRecords: - """Tests for save_installation_records method.""" - - def test_save_empty_list(self, tmp_path): - """Test saving empty list creates valid JSON.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.save_installation_records([]) - - assert tracking_file.exists() - - data = json.loads(tracking_file.read_text()) - assert data["installations"] == [] - assert "last_updated" in data - assert data["schema_version"] == "1.0" - - def test_save_records(self, tmp_path, sample_record, sample_record_2): - """Test saving records to JSON file.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.save_installation_records([sample_record, sample_record_2]) - - assert tracking_file.exists() - - data = json.loads(tracking_file.read_text()) - assert len(data["installations"]) == 2 - assert data["installations"][0]["id"] == sample_record.id - assert data["installations"][1]["id"] == sample_record_2.id - - def test_save_creates_parent_directory(self, tmp_path): - """Test that save creates parent directory if needed.""" - tracking_file = tmp_path / "nested" / "dir" / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.save_installation_records([]) - - assert tracking_file.exists() - assert tracking_file.parent.exists() - - -class TestAddInstallation: - """Tests for add_installation method.""" - - def test_add_to_empty(self, tmp_path, sample_record): - """Test adding installation to empty tracker.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - records = tracker.load_installation_records() - assert len(records) == 1 - assert records[0].id == sample_record.id - - def test_add_multiple(self, tmp_path, sample_record, sample_record_2): - """Test adding multiple installations.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - - records = tracker.load_installation_records() - assert len(records) == 2 - - -class TestGetInstallationById: - """Tests for get_installation_by_id method.""" - - def test_get_existing_installation(self, tmp_path, sample_record, sample_record_2): - """Test getting existing installation by ID.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - - found = tracker.get_installation_by_id(sample_record.id) - - assert found is not None - assert found.id == sample_record.id - assert found.template_name == "test-command" - - def test_get_nonexistent_installation(self, tmp_path): - """Test getting non-existent installation returns None.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - found = tracker.get_installation_by_id("nonexistent-id") - - assert found is None - - -class TestGetInstallationsByRepo: - """Tests for get_installations_by_repo method.""" - - def test_get_by_repo(self, tmp_path, sample_record, sample_record_2, different_repo_record): - """Test getting installations from specific repository.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - tracker.add_installation(different_repo_record) - - acme_records = tracker.get_installations_by_repo("acme-templates") - - assert len(acme_records) == 2 - assert all(r.source_repo == "acme-templates" for r in acme_records) - - def test_get_by_repo_none_found(self, tmp_path, sample_record): - """Test getting installations when none match repository.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - records = tracker.get_installations_by_repo("nonexistent-repo") - - assert records == [] - - -class TestGetInstallationsByNamespace: - """Tests for get_installations_by_namespace method.""" - - def test_get_by_namespace(self, tmp_path, sample_record, sample_record_2, different_repo_record): - """Test getting installations from specific namespace.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - tracker.add_installation(different_repo_record) - - acme_records = tracker.get_installations_by_namespace("acme") - - assert len(acme_records) == 2 - assert all(r.namespace == "acme" for r in acme_records) - - def test_get_by_namespace_none_found(self, tmp_path, sample_record): - """Test getting installations when none match namespace.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - records = tracker.get_installations_by_namespace("nonexistent") - - assert records == [] - - -class TestRemoveInstallation: - """Tests for remove_installation method.""" - - def test_remove_existing(self, tmp_path, sample_record, sample_record_2): - """Test removing existing installation.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - - result = tracker.remove_installation(sample_record.id) - - assert result is True - - records = tracker.load_installation_records() - assert len(records) == 1 - assert records[0].id == sample_record_2.id - - def test_remove_nonexistent(self, tmp_path, sample_record): - """Test removing non-existent installation returns False.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - result = tracker.remove_installation("nonexistent-id") - - assert result is False - - records = tracker.load_installation_records() - assert len(records) == 1 - - -class TestRemoveInstallationsByRepo: - """Tests for remove_installations_by_repo method.""" - - def test_remove_by_repo(self, tmp_path, sample_record, sample_record_2, different_repo_record): - """Test removing all installations from a repository.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - tracker.add_installation(different_repo_record) - - count = tracker.remove_installations_by_repo("acme-templates") - - assert count == 2 - - remaining = tracker.load_installation_records() - assert len(remaining) == 1 - assert remaining[0].source_repo == "company-templates" - - def test_remove_by_repo_none_found(self, tmp_path, sample_record): - """Test removing installations when none match repository.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - count = tracker.remove_installations_by_repo("nonexistent-repo") - - assert count == 0 - - records = tracker.load_installation_records() - assert len(records) == 1 - - -class TestUpdateInstallation: - """Tests for update_installation method.""" - - def test_update_existing(self, tmp_path, sample_record): - """Test updating existing installation.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - - # Create updated record - updated_record = TemplateInstallationRecord( - id=sample_record.id, - template_name="updated-command", - source_repo="acme-templates", - source_version="2.0.0", - namespace="acme", - installed_path="/project/.claude/commands/acme.updated.md", - scope=InstallationScope.PROJECT, - installed_at=datetime(2024, 1, 16, 10, 0, 0), - checksum="d" * 64, - ide_type=AIToolType.CLAUDE, - ) - - result = tracker.update_installation(sample_record.id, updated_record) - - assert result is True - - records = tracker.load_installation_records() - assert len(records) == 1 - assert records[0].template_name == "updated-command" - assert records[0].source_version == "2.0.0" - - def test_update_nonexistent(self, tmp_path, sample_record): - """Test updating non-existent installation returns False.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - result = tracker.update_installation("nonexistent-id", sample_record) - - assert result is False - - -class TestGetAllInstallations: - """Tests for get_all_installations method.""" - - def test_get_all(self, tmp_path, sample_record, sample_record_2): - """Test getting all installations.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - - all_records = tracker.get_all_installations() - - assert len(all_records) == 2 - - def test_get_all_empty(self, tmp_path): - """Test getting all installations when empty.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - all_records = tracker.get_all_installations() - - assert all_records == [] - - -class TestClearAllInstallations: - """Tests for clear_all_installations method.""" - - def test_clear_all(self, tmp_path, sample_record, sample_record_2): - """Test clearing all installations.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.add_installation(sample_record) - tracker.add_installation(sample_record_2) - - tracker.clear_all_installations() - - records = tracker.load_installation_records() - assert records == [] - - def test_clear_already_empty(self, tmp_path): - """Test clearing when already empty.""" - tracking_file = tmp_path / "installations.json" - tracker = TemplateInstallationTracker(tracking_file) - - tracker.clear_all_installations() - - records = tracker.load_installation_records() - assert records == [] diff --git a/tests/unit/test_template_validate.py b/tests/unit/test_template_validate.py deleted file mode 100644 index 8bebbe3..0000000 --- a/tests/unit/test_template_validate.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Tests for template validation command.""" - -import uuid -from datetime import datetime -from unittest.mock import patch - -import pytest -import typer - -from devsync.cli.template_validate import ( - ValidationIssue, - _display_validation_results, - _validate_installations, - validate_command, -) -from devsync.core.models import AIToolType, InstallationScope, TemplateInstallationRecord - - -class TestValidationIssue: - """Tests for ValidationIssue class.""" - - def test_initialization(self): - """Test ValidationIssue initialization.""" - issue = ValidationIssue( - severity="error", - template="test-template", - issue_type="missing_file", - description="File not found", - remediation="Reinstall template", - ) - - assert issue.severity == "error" - assert issue.template == "test-template" - assert issue.issue_type == "missing_file" - assert issue.description == "File not found" - assert issue.remediation == "Reinstall template" - - def test_initialization_with_default_remediation(self): - """Test ValidationIssue with empty remediation.""" - issue = ValidationIssue(severity="warning", template="test", issue_type="modified", description="File modified") - - assert issue.remediation == "" - - -class TestValidateInstallations: - """Tests for _validate_installations function.""" - - def test_no_installations(self, tmp_path): - """Test validation with no installations.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - tracker = TemplateInstallationTracker.for_project(tmp_path) - issues = _validate_installations(tracker, "project", verbose=False) - - assert issues == [] - - @patch("devsync.cli.template_validate.calculate_file_checksum") - def test_missing_file_issue(self, mock_checksum, tmp_path): - """Test detection of missing installed files.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - # Create tracker with installation record pointing to non-existent file - tracker = TemplateInstallationTracker.for_project(tmp_path) - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - namespace="acme", - template_name="test", - installed_path=str(tmp_path / "nonexistent.md"), - source_repo="https://github.com/acme/templates", - source_version="1.0.0", - checksum="a" * 64, # Valid SHA-256 checksum (64 hex chars) - scope=InstallationScope.PROJECT, - installed_at=datetime.now(), - ide_type=AIToolType.CLAUDE, - ) - tracker.save_installation_records([record]) - - issues = _validate_installations(tracker, "project", verbose=False) - - assert len(issues) == 1 - assert issues[0].severity == "error" - assert issues[0].issue_type == "missing_file" - assert issues[0].template == "acme.test" - - @patch("devsync.cli.template_validate.calculate_file_checksum") - @patch("devsync.cli.template_validate.TemplateLibraryManager") - def test_modified_file_issue(self, mock_library, mock_checksum, tmp_path): - """Test detection of locally modified files.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - # Create installed file - installed_file = tmp_path / "installed.md" - installed_file.write_text("modified content") - - # Setup tracker - tracker = TemplateInstallationTracker.for_project(tmp_path) - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - namespace="acme", - template_name="test", - installed_path=str(installed_file), - source_repo="https://github.com/acme/templates", - source_version="1.0.0", - checksum="b" * 64, # Valid original checksum - scope=InstallationScope.PROJECT, - installed_at=datetime.now(), - ide_type=AIToolType.CLAUDE, - ) - tracker.save_installation_records([record]) - - # Mock checksum to return different value (indicating modification) - mock_checksum.return_value = "c" * 64 # Different checksum - mock_library.return_value.get_repository_version.return_value = "1.0.0" - - issues = _validate_installations(tracker, "project", verbose=False) - - assert len(issues) == 1 - assert issues[0].severity == "warning" - assert issues[0].issue_type == "modified" - assert "modified locally" in issues[0].description - - @patch("devsync.cli.template_validate.calculate_file_checksum") - @patch("devsync.cli.template_validate.TemplateLibraryManager") - def test_outdated_version_issue(self, mock_library, mock_checksum, tmp_path): - """Test detection of outdated template versions.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - # Create installed file - installed_file = tmp_path / "installed.md" - installed_file.write_text("content") - - # Setup tracker - tracker = TemplateInstallationTracker.for_project(tmp_path) - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - namespace="acme", - template_name="test", - installed_path=str(installed_file), - source_repo="https://github.com/acme/templates", - source_version="1.0.0", - checksum="d" * 64, # Valid checksum - scope=InstallationScope.PROJECT, - installed_at=datetime.now(), - ide_type=AIToolType.CLAUDE, - ) - tracker.save_installation_records([record]) - - # Mock to indicate newer version available - mock_checksum.return_value = "d" * 64 # Unchanged - mock_library.return_value.get_repository_version.return_value = "2.0.0" - - issues = _validate_installations(tracker, "project", verbose=False) - - assert len(issues) == 1 - assert issues[0].severity == "info" - assert issues[0].issue_type == "outdated" - assert "Newer version available" in issues[0].description - - @patch("devsync.cli.template_validate.calculate_file_checksum") - @patch("devsync.cli.template_validate.TemplateLibraryManager") - @patch("devsync.cli.template_validate.console") - def test_checksum_exception_with_verbose(self, mock_console, mock_library, mock_checksum, tmp_path): - """Test verbose output when checksum verification fails.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - # Create installed file - installed_file = tmp_path / "installed.md" - installed_file.write_text("content") - - # Setup tracker - tracker = TemplateInstallationTracker.for_project(tmp_path) - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - namespace="acme", - template_name="test", - installed_path=str(installed_file), - source_repo="https://github.com/acme/templates", - source_version="1.0.0", - checksum="e" * 64, - scope=InstallationScope.PROJECT, - installed_at=datetime.now(), - ide_type=AIToolType.CLAUDE, - ) - tracker.save_installation_records([record]) - - # Mock checksum to raise exception - mock_checksum.side_effect = RuntimeError("Checksum error") - mock_library.return_value.get_repository_version.return_value = "1.0.0" - - issues = _validate_installations(tracker, "project", verbose=True) - - # Should handle exception gracefully and show verbose message - # No modified issue should be created - assert all(issue.issue_type != "modified" for issue in issues) - - @patch("devsync.cli.template_validate.calculate_file_checksum") - @patch("devsync.cli.template_validate.TemplateLibraryManager") - @patch("devsync.cli.template_validate.console") - def test_version_check_exception_with_verbose(self, mock_console, mock_library, mock_checksum, tmp_path): - """Test verbose output when version check fails.""" - from devsync.storage.template_tracker import TemplateInstallationTracker - - # Create installed file - installed_file = tmp_path / "installed.md" - installed_file.write_text("content") - - # Setup tracker - tracker = TemplateInstallationTracker.for_project(tmp_path) - record = TemplateInstallationRecord( - id=str(uuid.uuid4()), - namespace="acme", - template_name="test", - installed_path=str(installed_file), - source_repo="https://github.com/acme/templates", - source_version="1.0.0", - checksum="f" * 64, - scope=InstallationScope.PROJECT, - installed_at=datetime.now(), - ide_type=AIToolType.CLAUDE, - ) - tracker.save_installation_records([record]) - - # Mock to pass checksum but fail version check - mock_checksum.return_value = "f" * 64 - mock_library.return_value.get_repository_version.side_effect = RuntimeError("Version check error") - - issues = _validate_installations(tracker, "project", verbose=True) - - # Should handle exception gracefully and show verbose message - # No outdated issue should be created - assert all(issue.issue_type != "outdated" for issue in issues) - - -class TestDisplayValidationResults: - """Tests for _display_validation_results function.""" - - @patch("devsync.cli.template_validate.console") - def test_display_no_issues(self, mock_console): - """Test display with no validation issues.""" - _display_validation_results([], fix=False, verbose=False) - - # Should print success message - assert any("All templates are valid" in str(call) for call in mock_console.print.call_args_list) - - @patch("devsync.cli.template_validate.console") - def test_display_with_errors(self, mock_console): - """Test display with error-level issues.""" - issues = [ - ValidationIssue( - severity="error", - template="test", - issue_type="missing_file", - description="File not found", - remediation="Reinstall", - ) - ] - - with pytest.raises(typer.Exit) as exc_info: - _display_validation_results(issues, fix=False, verbose=False) - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.template_validate.console") - def test_display_with_warnings_only(self, mock_console): - """Test display with warning-level issues (no exit).""" - issues = [ - ValidationIssue( - severity="warning", - template="test", - issue_type="modified", - description="File modified", - remediation="Update", - ) - ] - - # Should not raise SystemExit for warnings - _display_validation_results(issues, fix=False, verbose=False) - - @patch("devsync.cli.template_validate.console") - def test_display_summary(self, mock_console): - """Test that summary is displayed correctly.""" - issues = [ - ValidationIssue("error", "test1", "missing_file", "File not found", "Reinstall"), - ValidationIssue("warning", "test2", "modified", "Modified", "Update"), - ValidationIssue("info", "test3", "outdated", "Outdated", "Upgrade"), - ] - - with pytest.raises(typer.Exit): - _display_validation_results(issues, fix=False, verbose=False) - - # Check summary was printed - print_calls = [str(call) for call in mock_console.print.call_args_list] - assert any("Validation Summary" in call for call in print_calls) - - -class TestValidateCommand: - """Tests for validate_command function.""" - - @patch("devsync.cli.template_validate.find_project_root") - @patch("devsync.cli.template_validate._validate_installations") - @patch("devsync.cli.template_validate._display_validation_results") - def test_validate_project_scope(self, mock_display, mock_validate, mock_find_root, tmp_path): - """Test validation with project scope.""" - mock_find_root.return_value = tmp_path - mock_validate.return_value = [] - - validate_command(scope="project", fix=False, verbose=False) - - mock_validate.assert_called_once() - mock_display.assert_called_once() - - @patch("devsync.cli.template_validate._validate_installations") - @patch("devsync.cli.template_validate._display_validation_results") - def test_validate_global_scope(self, mock_display, mock_validate): - """Test validation with global scope.""" - mock_validate.return_value = [] - - validate_command(scope="global", fix=False, verbose=False) - - mock_validate.assert_called_once() - mock_display.assert_called_once() - - @patch("devsync.cli.template_validate.find_project_root") - @patch("devsync.cli.template_validate._validate_installations") - @patch("devsync.cli.template_validate._display_validation_results") - def test_validate_all_scope(self, mock_display, mock_validate, mock_find_root, tmp_path): - """Test validation with 'all' scope.""" - mock_find_root.return_value = tmp_path - mock_validate.return_value = [] - - validate_command(scope="all", fix=False, verbose=False) - - # Should call validate twice (project + global) - assert mock_validate.call_count == 2 - mock_display.assert_called_once() - - @patch("devsync.cli.template_validate.console") - def test_validate_invalid_scope(self, mock_console): - """Test validation with invalid scope raises error.""" - with pytest.raises(typer.Exit) as exc_info: - validate_command(scope="invalid", fix=False, verbose=False) - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.template_validate.find_project_root") - @patch("devsync.cli.template_validate.console") - def test_validate_project_scope_no_project(self, mock_console, mock_find_root): - """Test project scope when not in a project directory.""" - mock_find_root.return_value = None - - with pytest.raises(typer.Exit) as exc_info: - validate_command(scope="project", fix=False, verbose=False) - - assert exc_info.value.exit_code == 1 - - @patch("devsync.cli.template_validate.find_project_root") - @patch("devsync.cli.template_validate._validate_installations") - def test_validate_keyboard_interrupt(self, mock_validate, mock_find_root, tmp_path): - """Test handling of keyboard interrupt during validation.""" - mock_find_root.return_value = tmp_path - mock_validate.side_effect = KeyboardInterrupt() - - with pytest.raises(typer.Exit) as exc_info: - validate_command(scope="project", fix=False, verbose=False) - - assert exc_info.value.exit_code == 130 # Standard SIGINT exit code - - @patch("devsync.cli.template_validate.find_project_root") - @patch("devsync.cli.template_validate._validate_installations") - def test_validate_generic_exception(self, mock_validate, mock_find_root, tmp_path): - """Test handling of unexpected exception during validation.""" - mock_find_root.return_value = tmp_path - mock_validate.side_effect = RuntimeError("Unexpected error") - - with pytest.raises(typer.Exit) as exc_info: - validate_command(scope="project", fix=False, verbose=False) - - assert exc_info.value.exit_code == 1 From 9b4ea2c1857b67e15f3e66518da39cc73776e66d Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:53:39 -0500 Subject: [PATCH 06/14] docs: update VISION.md and CLAUDE.md for v2 architecture (#76) Update product vision to reflect AI-powered config distribution. Update CLAUDE.md with v2 module structure, commands, and workflow. --- CLAUDE.md | 190 ++++++++++++++++++++++++++---------------------------- VISION.md | 107 ++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 100 deletions(-) create mode 100644 VISION.md diff --git a/CLAUDE.md b/CLAUDE.md index 46e267e..d3e3c14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,25 +4,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**DevSync** is a CLI tool for managing AI coding assistant instructions. It allows users to download instruction repositories to a local library, browse them with an interactive TUI, and install them to AI tools (Cursor, Claude Code, Windsurf, GitHub Copilot) at the project level. +**DevSync** is a CLI tool for AI-powered config distribution across AI coding assistants. It uses LLM intelligence to extract practices from projects and adapt them to recipients' existing setups — supporting 23+ AI tools including Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, and Codex. -**CLI entry point:** `aiconfig` (installed via `pip install devsync`) +**CLI entry point:** `devsync` (installed via `pip install devsync`) ## Architecture ### Core Concepts -1. **Library System**: Instructions are downloaded from Git repos or local folders to `~/.devsync/library/` organized by namespace -2. **Project-Level Installation**: All installations are project-specific, stored in tool-specific directories (`.cursor/rules/`, `.claude/rules/`, `.kiro/steering/`, etc.) -3. **Installation Tracking**: Tracked in `/.devsync/installations.json` for each project (instructions) and `/.devsync/packages.json` for packages -4. **Interactive TUI**: Terminal UI for browsing and selecting instructions from the library -5. **Configuration Packages**: Multi-component packages containing instructions, MCP servers, hooks, commands, and resources that can be installed as a unit +1. **AI-Powered Extraction**: LLM reads a project's rules, MCP configs, and commands to produce abstract practice declarations (not file copies) +2. **AI-Powered Installation**: LLM adapts incoming practices to recipient's existing setup with intelligent merging and conflict resolution +3. **Project-Level Installation**: All installations are project-specific, stored in tool-specific directories (`.cursor/rules/`, `.claude/rules/`, `.kiro/steering/`, etc.) +4. **Installation Tracking**: Tracked in `/.devsync/packages.json` for packages +5. **Graceful Degradation**: No API key? Extract copies files verbatim, install uses file-copy mode. `--no-ai` flag forces this explicitly +6. **v1 Backward Compatibility**: Old `ai-config-kit-package.yaml` packages install via file-copy mode. v2 `devsync-package.yaml` adds `practices` section for AI-native content ### Package Structure ``` -ai-config-kit/ -├── ai_tools/ # AI tool integrations and detection +devsync/ +├── ai_tools/ # AI tool integrations and detection (23+ tools) │ ├── base.py # Abstract AITool base class │ ├── claude.py # Claude Code (.claude/rules/*.md) │ ├── cursor.py # Cursor (.cursor/rules/*.mdc) @@ -34,30 +35,36 @@ ai-config-kit/ │ ├── copilot.py # GitHub Copilot (.github/instructions/*.md) │ ├── anteroom.py # Anteroom (ANTEROOM.md sections) │ └── detector.py # Tool detection logic -├── cli/ # Typer CLI commands -│ ├── main.py # CLI app definition -│ ├── download.py # Download repos to library -│ ├── install.py # Legacy install command -│ ├── install_new.py # New install with TUI -│ ├── list.py # List library/installed/available -│ ├── update.py # Update library repos -│ ├── delete.py # Delete from library +├── cli/ # Typer CLI commands (v2 — 6 commands) +│ ├── main.py # CLI app definition (setup, tools, extract, install, list, uninstall) +│ ├── setup.py # Configure LLM provider +│ ├── extract.py # AI extraction command +│ ├── install_v2.py # AI-powered install command +│ ├── list_v2.py # Simplified list command │ ├── uninstall.py # Uninstall from projects -│ ├── tools.py # List detected AI tools -│ ├── package.py # Package management commands (list, uninstall) -│ └── package_install.py # Package installation logic +│ └── tools.py # List detected AI tools ├── core/ # Core business logic │ ├── models.py # Data models (Instruction, Repository, etc.) +│ ├── practice.py # PracticeDeclaration, MCPDeclaration, CredentialSpec +│ ├── extractor.py # AI extraction engine +│ ├── adapter.py # AI adaptation/merge engine +│ ├── package_manifest_v2.py # v2 manifest parser (v1 compat) +│ ├── mcp_credential_prompter.py # MCP credential prompting │ ├── repository.py # Parse ai-config-kit.yaml │ ├── git_operations.py # Git clone/pull operations │ ├── checksum.py # File integrity checking │ └── conflict_resolution.py # Handle file conflicts +├── llm/ # LLM provider abstraction (HTTP-only, no SDK deps) +│ ├── provider.py # Abstract LLMProvider, LLMResponse, resolve_provider() +│ ├── anthropic.py # Anthropic Claude (HTTP via httpx) +│ ├── openai_provider.py # OpenAI (HTTP via httpx) +│ ├── openrouter.py # OpenRouter (HTTP via httpx) +│ ├── config.py # API key resolution, ~/.devsync/config.yaml +│ ├── prompts.py # All prompt templates +│ └── response_models.py # Structured response types ├── storage/ # Data persistence -│ ├── library.py # LibraryManager for ~/.devsync/library/ │ ├── tracker.py # InstallationTracker for installations.json │ └── package_tracker.py # PackageTracker for packages.json -├── tui/ # Terminal UI -│ └── installer.py # Textual-based interactive browser └── utils/ # Utilities ├── project.py # Project root detection └── logging.py # Logging configuration @@ -206,73 +213,39 @@ tests/ **Commit Message Format:** ``` -: - -[optional issue reference] - - +type(scope): description (#issue) ``` **IMPORTANT:** Do NOT include Claude as a co-author in commit messages. Do NOT add: -- `🤖 Generated with [Claude Code](https://claude.com/claude-code)` - `Co-Authored-By: Claude ` +- `Co-Authored-By: Claude Opus 4.6 ` +- `🤖 Generated with [Claude Code](https://claude.com/claude-code)` - Any other Claude attribution lines -**Types:** -- `feat`: New feature -- `fix`: Bug fix -- `test`: Adding or updating tests -- `refactor`: Code refactoring -- `docs`: Documentation changes -- `chore`: Maintenance tasks -- `perf`: Performance improvements - -**Issue References:** -Always reference GitHub issues in commit messages to create automatic links and tracking: - -- **Closing issues**: Use `Fixes #123`, `Closes #123`, or `Resolves #123` in the commit body to automatically close the issue when merged to main - ``` - fix: remove duplicate installation confirmation prompt - - Fixes #1 - - The aiconfig install command was prompting users twice... - ``` - -- **Referencing issues**: Use `Refs #123` or `See #123` to reference related issues without closing them - ``` - test: add unit tests for duplicate confirmation fix - - Refs #1 - - Add comprehensive unit tests... - ``` +**Rules:** +- **type** must be one of: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` +- **scope** must be a module name: `cli`, `core`, `storage`, `ai_tools`, `tui`, `utils` +- **description** is lowercase, imperative mood, no trailing period +- **#issue** is a valid GitHub issue number — every commit MUST reference one +- `docs` type may omit scope when the change is project-wide **Examples:** ```bash -# Bug fix that closes an issue -git commit -m "fix: handle empty library gracefully - -Fixes #42 - -Previously the CLI would crash when the library was empty. -This commit adds proper error handling..." - -# Test addition referencing an issue -git commit -m "test: add tests for library edge cases +# Feature with scope and issue +git commit -m "feat(ai_tools): add zed tool support (#83)" -Refs #42 +# Bug fix +git commit -m "fix(storage): handle missing installations.json (#91)" -Adds tests to verify empty library handling..." +# Test addition +git commit -m "test(core): add package model validation tests (#88)" -# Feature with multiple issue references -git commit -m "feat: add batch installation support - -Closes #15, Refs #12 - -Allows installing multiple instructions in one command..." +# Docs (scope optional for project-wide) +git commit -m "docs: update CLAUDE.md architecture section (#95)" ``` +**Branch naming:** `issue--short-description` (e.g., `issue-83-zed-tool-support`) + ## Important Implementation Details ### Project Root Detection @@ -361,37 +334,44 @@ components: size: 1234 ``` -#### Package Commands +#### v2 Commands ```bash -# Install a package -aiconfig package install ./path/to/package --ide claude +# Configure LLM provider (one-time) +devsync setup -# Install with conflict resolution -aiconfig package install ./package --ide cursor --conflict overwrite +# Detect installed AI tools +devsync tools -# Force reinstall -aiconfig package install ./package --force +# Extract practices from a project +devsync extract +devsync extract --no-ai # File-copy mode +devsync extract --output ./pkg --name team-standards -# List installed packages -aiconfig package list - -# List with JSON output -aiconfig package list --json +# Install a package +devsync install ./team-standards +devsync install https://github.com/company/standards +devsync install ./package --tool claude --tool cursor +devsync install ./package --no-ai +devsync install ./package --conflict skip -# Uninstall a package -aiconfig package uninstall package-name +# List installed packages +devsync list +devsync list --tool claude +devsync list --json -# Uninstall without confirmation -aiconfig package uninstall package-name --yes +# Uninstall +devsync uninstall team-standards +devsync uninstall team-standards --force ``` -#### Package Installation Workflow -1. **Parse Manifest**: Read and validate `ai-config-kit-package.yaml` -2. **Check Existing**: Detect if package already installed -3. **Filter Components**: Only install components supported by target IDE -4. **Translate Components**: Convert to IDE-specific formats -5. **Install Files**: Copy files with conflict resolution -6. **Track Installation**: Record in `.devsync/packages.json` +#### v2 Installation Workflow +1. **Parse Manifest**: Read and validate `devsync-package.yaml` or `ai-config-kit-package.yaml` (v1 compat) +2. **Auto-detect tools**: Detect installed AI tools +3. **AI adaptation**: Use LLM to semantically merge practices with existing rules +4. **Display plan**: Show user what will be installed/merged/skipped +5. **Confirm and execute**: Write adapted files to tool-specific directories +6. **MCP credentials**: Prompt for any required MCP server credentials +7. **Track Installation**: Record in `.devsync/packages.json` #### IDE Capability Filtering Different IDEs support different component types: @@ -487,7 +467,7 @@ Follow the `.cursor/rules/documentation-practices.mdc` guide: ```bash # Enable debug logging -LOGLEVEL=DEBUG aiconfig install +LOGLEVEL=DEBUG devsync install # Run specific test with output pytest tests/unit/test_models.py -s -vv @@ -752,6 +732,16 @@ gh run watch The GitHub Actions workflow (`.github/workflows/publish.yml`) handles building and publishing automatically. +## Developer Workflow + +This project uses Claude Code skills (`.claude/commands/`) and auto-loaded rules (`.claude/rules/`) to enforce development standards. See `VISION.md` for product identity and scope guardrails. See `ROADMAP.md` for the prioritized roadmap organized by VISION.md direction areas. + +**Skills** (invoke with `/command`): `/ideate`, `/new-issue`, `/start-work`, `/commit`, `/submit-pr`, `/pr-check`, `/code-review`, `/deploy`, `/cleanup`, `/next`, `/triage`, `/write-docs`, `/dev-help`. Run `/dev-help` for a full guide. + +**SpecKit Skills** (invoke with `/speckit.`): `/speckit.analyze`, `/speckit.checklist`, `/speckit.clarify`, `/speckit.constitution`, `/speckit.implement`, `/speckit.plan`, `/speckit.specify`, `/speckit.tasks`. + +**Rules** (auto-loaded every session): commit format, issue requirement, output formatting, product vision alignment, security patterns, test requirements. + ## Active Technologies - Markdown (instruction content) | Python 3.10+ (for DevSync CLI - no changes needed) + Git (for repository hosting), existing DevSync commands (no new dependencies) (001-example-instruction-repo) - GitHub repository at `troylar/config-sync-examples` | Git-based versioning (001-example-instruction-repo) diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..80dced3 --- /dev/null +++ b/VISION.md @@ -0,0 +1,107 @@ +# DevSync — Product Vision + +## What DevSync Is + +DevSync is a CLI tool for AI-powered config distribution across AI coding assistants. It uses LLM intelligence to extract practices from projects and adapt them to recipients' existing setups — across Cursor, Claude Code, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more. + +**Install it with pip. Extract and install in two commands. No lock-in.** + +Think of it as an AI-powered package manager for coding assistant configurations. Extract practices from any project, share them as packages, install them with intelligent merging into any of 23+ supported AI tools. + +## Who It's For + +### Primary: Teams standardizing AI tool configurations +Organizations that want consistent AI coding instructions across developers, tools, and projects — without manual copying or tool-specific formats. + +### Secondary: Open-source instruction authors +Developers who create and share reusable AI coding instructions, MCP server configs, and development workflows via Git repositories. + +### Tertiary: Individual developers +Power users who want to manage their own AI tool configurations across multiple projects and tools from a single library. + +## Core Principles + +These principles guide every feature decision. New features must align with at least one and violate none. + +### 1. Zero-friction distribution +`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. + +### 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. + +### 4. Lean CLI +Typer + Rich, no daemons, no services, no background processes. DevSync runs when you invoke it and exits. It doesn't watch files, it doesn't sync automatically, it doesn't phone home. Simple, predictable, fast. + +### 5. Credential safety +Credentials never in repos, always prompted or from environment variables. MCP server configs can declare required credentials, but the values are never stored in package manifests or installation records. Secrets stay in the environment. + +### 6. Standards-first +MCP protocol for server configs, conventional markdown for instructions, YAML manifests for packages. Standard formats over proprietary ones. Users can read, edit, and version-control everything DevSync creates. + +## What's In Scope + +- **AI-powered extraction**: Read a project's rules, MCP configs, and commands to produce abstract practice declarations +- **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 +- **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 +- **Git integration**: Clone and install from any Git repository + +## What DevSync Is Not + +These aren't just out-of-scope items — they're identity statements. + +### Not an IDE +DevSync manages configurations for AI tools. It doesn't provide AI assistance itself, doesn't run models, doesn't execute code. It's the plumbing, not the faucet. + +### Not a cloud service +No hosted registry, no accounts, no SaaS tier. DevSync is a local CLI tool that talks to Git repos. The moment it requires a server to function, the design is wrong. + +### Not a code generator +DevSync distributes instructions that humans or teams write. It doesn't generate, suggest, or modify instruction content. Creation is the author's job; distribution is DevSync's. + +### Not a plugin framework +DevSync installs files to the right places in the right formats. It doesn't provide a runtime, an API, or an extension system. AI tools have their own extension mechanisms; DevSync just gets configs there. + +### Not a package registry +There's no central registry, no publishing step, no approval process. Any Git repo with a manifest file is a valid source. Discovery happens through URLs, not through a catalog. + +### Not a configuration burden +Zero configuration should always work. `devsync download ` and `devsync install` should work without any setup beyond `pip install`. Power users can customize, but defaults must be sensible. + +## What's Out of Scope + +These are explicit "no" decisions. Do not build features in these areas. + +- **Cloud hosting / SaaS**: DevSync is always local. No managed version, no hosted tier. +- **AI model interaction beyond config distribution**: DevSync uses LLMs for extraction and adaptation of configs, but does not provide general AI assistance or generate application code. +- **IDE plugins / extensions**: DevSync is CLI-only. IDE integration happens through the files it installs, not through plugins. +- **Automatic syncing / file watching**: DevSync runs on demand. No daemons, no background processes. +- **Instruction content creation**: DevSync distributes; it doesn't author. +- **Complex deployment requirements**: If it needs more than `pip install`, it doesn't belong. + +## Direction (Current) + +The project is heading toward four areas: + +1. **AI-Powered Distribution** — LLM-powered extraction and adaptation of practices, intelligent merging with existing configs, graceful degradation when no API key is available. + +2. **AI Tool Integration** — Support for new and evolving AI coding assistants (23+ tools), improved translation between formats, better detection of installed tools. + +3. **Package Ecosystem** — v2 practice-based packages, v1 backward compatibility, MCP server credential handling, Git-based distribution. + +4. **Docs & Quality** — Documentation, test coverage, CI improvements, contributor experience. + +## The Litmus Test + +Before adding a feature, ask: + +1. **Does it work with `pip install`?** If it adds heavy dependencies or external infrastructure, reconsider. +2. **Is it IDE-agnostic?** If it only works with one AI tool, it might not belong in core. +3. **Is it lean?** Could we do this with less code, fewer dependencies, simpler UX? +4. **Does it work offline?** DevSync should work without network access (for already-downloaded repos). +5. **Would the team use it?** If developers wouldn't reach for this feature regularly, it probably shouldn't exist. From 508dc4110f96aabcb4d949a6bda4d6f195f6135e Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:55:16 -0500 Subject: [PATCH 07/14] feat(cli): add v1 package upgrade tool (#78) Add --upgrade flag to extract command that converts v1 ai-config-kit packages to v2 devsync-package format. Supports AI-powered conversion when LLM is configured, falls back to file-copy mode otherwise. --- devsync/cli/extract.py | 119 ++++++++++++++++++++++++++++++++- devsync/cli/main.py | 21 ++---- tests/unit/cli/test_extract.py | 67 ++++++++++++++++++- 3 files changed, 191 insertions(+), 16 deletions(-) diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index 927c072..64be7ef 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -9,7 +9,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from devsync.core.extractor import PracticeExtractor -from devsync.core.package_manifest_v2 import PackageManifestV2 +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 @@ -21,6 +21,7 @@ def extract_command( name: Optional[str] = None, no_ai: bool = False, project_dir: Optional[str] = None, + upgrade: Optional[str] = None, ) -> int: """Extract practices from the current project into a shareable package. @@ -29,10 +30,14 @@ def extract_command( name: Package name. Defaults to project directory name. 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. Returns: Exit code (0 = success). """ + if upgrade: + return _upgrade_v1_package(upgrade, output=output, name=name, no_ai=no_ai) + 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]") @@ -101,6 +106,118 @@ def extract_command( return 0 +def _upgrade_v1_package( + v1_path: str, + output: Optional[str] = None, + name: Optional[str] = None, + no_ai: bool = False, +) -> int: + """Convert a v1 package to v2 format. + + Reads the v1 manifest, extracts instruction files, and produces a v2 + package with practice declarations (AI-powered) or literal content (no-AI). + + Args: + v1_path: Path to the v1 package directory. + output: Output directory for the v2 package. + name: Package name override. + no_ai: Disable AI-powered conversion. + + Returns: + Exit code (0 = success). + """ + package_path = Path(v1_path).expanduser() + if not package_path.is_dir(): + console.print(f"[red]Not a directory: {package_path}[/red]") + return 1 + + fmt = detect_manifest_format(package_path) + if not fmt: + console.print(f"[red]No manifest found in {package_path}[/red]") + console.print("Expected: ai-config-kit-package.yaml or devsync-package.yaml") + return 1 + + if fmt == "v2": + console.print("[yellow]Package is already v2 format. No upgrade needed.[/yellow]") + return 0 + + v1_manifest = parse_manifest(package_path) + console.print(f"\n[bold]Upgrading v1 package: {v1_manifest.name} v{v1_manifest.version}[/bold]") + + instruction_files: dict[str, str] = {} + for comp_type, refs in v1_manifest.components.items(): + if comp_type != "instructions": + continue + for ref in refs: + src_file = package_path / ref.file + if src_file.exists() and src_file.stat().st_size < 100_000: + try: + content = src_file.read_text(encoding="utf-8") + instruction_files[ref.file] = content + except (OSError, UnicodeDecodeError): + console.print(f" [yellow]Could not read: {ref.file}[/yellow]") + + if not instruction_files: + console.print("[yellow]No instruction files found in v1 package.[/yellow]") + return 0 + + llm = None + if not no_ai: + config = load_config() + llm = resolve_provider(preferred_provider=config.provider, preferred_model=config.model) + if not llm: + console.print("[yellow]No LLM API key found. Using file-copy mode.[/yellow]") + + extractor = PracticeExtractor(llm_provider=llm) + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + ) as progress: + task = progress.add_task("Converting to v2...", total=None) + if llm: + result = extractor._extract_with_ai(instruction_files, []) + else: + result = extractor._extract_without_ai(instruction_files, []) + progress.update(task, description="Building v2 package...") + + output_path = Path(output) if output else package_path.parent / f"{package_path.name}-v2" + output_path.mkdir(parents=True, exist_ok=True) + + package_name = name or v1_manifest.name + + v2_manifest = PackageManifestV2( + format_version="2.0", + name=package_name, + version=v1_manifest.version, + description=v1_manifest.description or f"Upgraded from v1: {v1_manifest.name}", + practices=result.practices, + mcp_servers=result.mcp_servers, + ) + + if not result.ai_powered: + _copy_source_files(package_path, output_path, list(instruction_files.keys())) + from devsync.core.package_manifest_v2 import ComponentRef + + v2_manifest.components = { + "instructions": [ + ComponentRef(name=Path(f).stem, file=f"instructions/{Path(f).name}") + for f in instruction_files + ] + } + + manifest_path = output_path / "devsync-package.yaml" + manifest_path.write_text(v2_manifest.to_yaml()) + + mode = "[green]AI-powered[/green]" if result.ai_powered else "[yellow]file-copy[/yellow]" + console.print(f"\nUpgraded ({mode}):") + console.print(f" Practices: {len(result.practices)}") + console.print(f" Source files: {len(instruction_files)}") + console.print(f"\nv2 package written to: [cyan]{output_path}[/cyan]") + return 0 + + def _copy_source_files(project_path: Path, output_path: Path, source_files: list[str]) -> None: """Copy source instruction files to the output package directory.""" instructions_dir = output_path / "instructions" diff --git a/devsync/cli/main.py b/devsync/cli/main.py index 7e87285..df6749c 100644 --- a/devsync/cli/main.py +++ b/devsync/cli/main.py @@ -91,20 +91,13 @@ def extract( """ from devsync.cli.extract import extract_command - if upgrade: - exit_code = extract_command( - output=output, - name=name, - no_ai=no_ai, - project_dir=upgrade, - ) - else: - exit_code = extract_command( - output=output, - name=name, - no_ai=no_ai, - project_dir=project_dir, - ) + exit_code = extract_command( + output=output, + name=name, + no_ai=no_ai, + project_dir=project_dir, + upgrade=upgrade, + ) raise typer.Exit(code=exit_code) diff --git a/tests/unit/cli/test_extract.py b/tests/unit/cli/test_extract.py index dd760f1..bde8813 100644 --- a/tests/unit/cli/test_extract.py +++ b/tests/unit/cli/test_extract.py @@ -6,7 +6,7 @@ import pytest import yaml -from devsync.cli.extract import extract_command +from devsync.cli.extract import _upgrade_v1_package, extract_command from devsync.core.practice import PracticeDeclaration from devsync.llm.response_models import ExtractionResult @@ -59,3 +59,68 @@ def test_extract_no_api_key_fallback( assert result == 0 mock_extractor_cls.assert_called_once_with(llm_provider=None) + + +class TestUpgradeV1Package: + def test_upgrade_nonexistent_path(self) -> None: + result = _upgrade_v1_package("/nonexistent/path") + assert result == 1 + + def test_upgrade_no_manifest(self, tmp_path: Path) -> None: + result = _upgrade_v1_package(str(tmp_path)) + assert result == 1 + + def test_upgrade_already_v2(self, tmp_path: Path) -> None: + manifest = {"format_version": "2.0", "name": "test", "version": "1.0.0"} + (tmp_path / "devsync-package.yaml").write_text(yaml.dump(manifest)) + result = _upgrade_v1_package(str(tmp_path)) + assert result == 0 + + def test_upgrade_v1_no_ai(self, tmp_path: Path) -> None: + instructions_dir = tmp_path / "instructions" + instructions_dir.mkdir() + (instructions_dir / "style.md").write_text("# Style\nUse black.") + + manifest = { + "name": "old-pkg", + "version": "1.0.0", + "description": "Legacy package", + "components": { + "instructions": [{"name": "style", "file": "instructions/style.md"}], + }, + } + (tmp_path / "ai-config-kit-package.yaml").write_text(yaml.dump(manifest)) + + output_dir = tmp_path / "output" + result = _upgrade_v1_package(str(tmp_path), output=str(output_dir), no_ai=True) + + assert result == 0 + v2_manifest_path = output_dir / "devsync-package.yaml" + assert v2_manifest_path.exists() + + v2 = yaml.safe_load(v2_manifest_path.read_text()) + assert v2["format_version"] == "2.0" + assert v2["name"] == "old-pkg" + assert v2["version"] == "1.0.0" + assert len(v2.get("practices", [])) == 1 + + def test_upgrade_dispatched_from_extract_command(self, tmp_path: Path) -> None: + instructions_dir = tmp_path / "instructions" + instructions_dir.mkdir() + (instructions_dir / "rule.md").write_text("# Rule\nDo this.") + + manifest = { + "name": "v1-pkg", + "version": "0.5.0", + "description": "Old", + "components": { + "instructions": [{"name": "rule", "file": "instructions/rule.md"}], + }, + } + (tmp_path / "ai-config-kit-package.yaml").write_text(yaml.dump(manifest)) + + output_dir = tmp_path / "upgraded" + result = extract_command(output=str(output_dir), no_ai=True, upgrade=str(tmp_path)) + + assert result == 0 + assert (output_dir / "devsync-package.yaml").exists() From fc00219a187bde9291aa6de0253f17662faab3e6 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:55:48 -0500 Subject: [PATCH 08/14] docs: update README for v2 AI-powered architecture (#77) Rewrite README to focus on two-command flow (extract + install), AI-powered features, and v1 migration path. --- README.md | 51 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7fbc4ee..70f839b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # DevSync -**Distribute and sync AI coding assistant configurations across your team** +**AI-powered config distribution for AI coding assistants** [![CI](https://github.com/troylar/devsync/actions/workflows/ci.yml/badge.svg)](https://github.com/troylar/devsync/actions/workflows/ci.yml) [![Docs](https://readthedocs.org/projects/devsync/badge/?version=latest)](https://devsync.readthedocs.io) @@ -17,31 +17,58 @@ --- -DevSync is a CLI tool for managing AI coding assistant instructions, MCP servers, and configuration packages across 22+ IDEs. Download shared configs from Git repos, install them to any tool, and keep your team aligned. +DevSync uses LLM intelligence to extract coding practices from projects and adapt them to recipients' existing setups -- across 23+ AI coding assistants. Two commands: `extract` and `install`. ## Quick Start ```bash pip install devsync +# One-time: configure your LLM provider +devsync setup + # Check detected AI tools devsync tools -# Download instructions from a Git repo -devsync download --from github.com/company/standards --as company +# Extract practices from a project +devsync extract + +# Install a package into another project +devsync install ./team-standards -# Install interactively -devsync install +# Install from Git +devsync install https://github.com/company/standards ``` +No API key? DevSync works without one -- it falls back to file-copy mode. Add `--no-ai` to any command to force this. + ## Features -- **Instructions** -- share coding standards, style guides, and AI prompts from Git repos -- **MCP Servers** -- distribute Model Context Protocol configs with secure credential management -- **Packages** -- bundle instructions, MCP servers, hooks, commands, and resources together -- **23 IDE integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, and 19 more -- **Templates** -- IDE-targeted content with slash commands, hooks, and backups -- **Conflict resolution** -- skip, overwrite, or rename when files already exist +- **AI-powered extraction** -- LLM reads your project's rules, MCP configs, and commands to produce abstract practice declarations +- **AI-powered installation** -- LLM adapts incoming practices to your existing setup with intelligent merging +- **23+ AI tool integrations** -- Claude Code, Cursor, Windsurf, GitHub Copilot, Kiro, Roo Code, Cline, Codex, and more +- **MCP credential handling** -- prompts for credentials at install time, never stores them in repos +- **v1 backward compatibility** -- old `ai-config-kit-package.yaml` packages still install via file-copy +- **Graceful degradation** -- works without an API key, `--no-ai` flag for explicit file-copy mode + +## Commands + +| Command | Description | +|---------|-------------| +| `devsync setup` | Configure LLM provider (Anthropic, OpenAI, OpenRouter) | +| `devsync tools` | Detect installed AI coding tools | +| `devsync extract` | Extract practices from current project into a shareable package | +| `devsync install ` | Install a package with AI-powered adaptation | +| `devsync list` | Show installed packages | +| `devsync uninstall ` | Remove an installed package | + +## Migrating from v1 + +If you have v1 packages (`ai-config-kit-package.yaml`), they still work with `devsync install`. To upgrade them to v2 format: + +```bash +devsync extract --upgrade ./old-package +``` ## Documentation From 8250fc1e0ce2b24ce1f7490c810dc621385e6d4f Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:05:23 -0500 Subject: [PATCH 09/14] fix(cli): resolve lint, format, and type errors in v2 modules (#63) Fix unused variables, incorrect method names (list_packages -> get_installed_packages), incorrect import (clone_repository -> GitOperations.clone_repository), and apply black formatting. --- devsync/cli/extract.py | 4 +--- devsync/cli/install_v2.py | 11 +++-------- devsync/cli/list_v2.py | 3 +-- devsync/core/adapter.py | 8 ++------ devsync/core/extractor.py | 8 ++------ devsync/core/package_manifest_v2.py | 8 ++------ tests/unit/cli/test_install_v2.py | 5 +++-- tests/unit/cli/test_list_v2.py | 11 ++++------- tests/unit/core/test_package_manifest_v2.py | 22 +++++---------------- 9 files changed, 23 insertions(+), 57 deletions(-) diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index 64be7ef..740920b 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -import typer from rich.console import Console from rich.progress import Progress, SpinnerColumn, TextColumn @@ -202,8 +201,7 @@ def _upgrade_v1_package( v2_manifest.components = { "instructions": [ - ComponentRef(name=Path(f).stem, file=f"instructions/{Path(f).name}") - for f in instruction_files + ComponentRef(name=Path(f).stem, file=f"instructions/{Path(f).name}") for f in instruction_files ] } diff --git a/devsync/cli/install_v2.py b/devsync/cli/install_v2.py index bc4d94e..ec3c2a6 100644 --- a/devsync/cli/install_v2.py +++ b/devsync/cli/install_v2.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -import typer from rich.console import Console from rich.prompt import Confirm from rich.table import Table @@ -88,10 +87,10 @@ def _resolve_source(source: str) -> Optional[Path]: def _clone_source(url: str) -> Optional[Path]: """Clone a Git repository to a temp directory.""" try: - from devsync.core.git_operations import clone_repository + from devsync.core.git_operations import GitOperations tmp_dir = Path(tempfile.mkdtemp(prefix="devsync-")) - clone_repository(url, tmp_dir) + GitOperations.clone_repository(url, tmp_dir) return tmp_dir except Exception as e: console.print(f"[red]Failed to clone {url}: {e}[/red]") @@ -147,10 +146,6 @@ def _install_v2_fallback( conflict: str, ) -> int: """Install using file-copy mode (v1 compat or --no-ai).""" - from devsync.core.models import ConflictResolution - - conflict_strategy = ConflictResolution(conflict) if conflict != "prompt" else ConflictResolution.SKIP - installed_count = 0 for component_type, refs in manifest.components.items(): @@ -232,7 +227,7 @@ def _install_mcp_servers(manifest: PackageManifestV2, project_root: Path) -> Non for server in manifest.mcp_servers: server_creds = credentials.get(server.name, {}) - config = build_mcp_config(server, server_creds) + build_mcp_config(server, server_creds) console.print(f" MCP: {server.name} configured") else: for server in manifest.mcp_servers: diff --git a/devsync/cli/list_v2.py b/devsync/cli/list_v2.py index f630545..cc26f0e 100644 --- a/devsync/cli/list_v2.py +++ b/devsync/cli/list_v2.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import Optional -import typer from rich.console import Console from rich.table import Table @@ -34,7 +33,7 @@ def list_v2_command( tracker = PackageTracker(project_root) try: - packages = tracker.list_packages() + packages = tracker.get_installed_packages() except Exception: packages = [] diff --git a/devsync/core/adapter.py b/devsync/core/adapter.py index b64510e..6d14cce 100644 --- a/devsync/core/adapter.py +++ b/devsync/core/adapter.py @@ -6,14 +6,12 @@ from typing import Optional from devsync.core.practice import PracticeDeclaration -from devsync.llm.prompts import ADAPT_PRACTICE_PROMPT, MERGE_PRACTICES_PROMPT, SYSTEM_PROMPT +from devsync.llm.prompts import ADAPT_PRACTICE_PROMPT, SYSTEM_PROMPT from devsync.llm.provider import LLMProvider, LLMProviderError from devsync.llm.response_models import ( AdaptationAction, AdaptationPlan, - MergeDecision, parse_adaptation_response, - parse_merge_response, ) logger = logging.getLogger(__name__) @@ -85,9 +83,7 @@ def _adapt_with_ai( """Use LLM to create semantic adaptation plan.""" assert self._llm is not None actions: list[AdaptationAction] = [] - existing_summary = "\n".join( - f"--- {path} ---\n{content[:500]}" for path, content in existing_rules.items() - ) + existing_summary = "\n".join(f"--- {path} ---\n{content[:500]}" for path, content in existing_rules.items()) for practice in practices: try: diff --git a/devsync/core/extractor.py b/devsync/core/extractor.py index fbf6c5d..8e3da42 100644 --- a/devsync/core/extractor.py +++ b/devsync/core/extractor.py @@ -80,9 +80,7 @@ def _read_mcp_configs(self, detection: object) -> list[dict]: configs.append(config) return configs - def _extract_with_ai( - self, files: dict[str, str], mcp_configs: list[dict] - ) -> ExtractionResult: + def _extract_with_ai(self, files: dict[str, str], mcp_configs: list[dict]) -> ExtractionResult: """Extract practices using LLM intelligence.""" assert self._llm is not None @@ -127,9 +125,7 @@ def _extract_with_ai( ai_powered=True, ) - def _extract_without_ai( - self, files: dict[str, str], mcp_configs: list[dict] - ) -> ExtractionResult: + def _extract_without_ai(self, files: dict[str, str], mcp_configs: list[dict]) -> ExtractionResult: """Extract practices as literal file copies (no AI).""" practices = self._practices_from_files(files) diff --git a/devsync/core/package_manifest_v2.py b/devsync/core/package_manifest_v2.py index c0a7c43..d55b524 100644 --- a/devsync/core/package_manifest_v2.py +++ b/devsync/core/package_manifest_v2.py @@ -117,9 +117,7 @@ def to_dict(self) -> dict: if self.mcp_servers: result["mcp_servers"] = [m.to_dict() for m in self.mcp_servers] if self.has_components: - result["components"] = { - key: [c.to_dict() for c in refs] for key, refs in self.components.items() if refs - } + result["components"] = {key: [c.to_dict() for c in refs] for key, refs in self.components.items() if refs} return result def to_yaml(self) -> str: @@ -206,9 +204,7 @@ def _parse_v1(manifest_path: Path) -> PackageManifestV2: mcp_servers = [] for m in data.get("components", {}).get("mcp_servers", []): - creds = [ - CredentialSpec.from_dict(c) for c in m.get("credentials", []) - ] + creds = [CredentialSpec.from_dict(c) for c in m.get("credentials", [])] mcp_servers.append( MCPDeclaration( name=m["name"], diff --git a/tests/unit/cli/test_install_v2.py b/tests/unit/cli/test_install_v2.py index 84443c4..f260e74 100644 --- a/tests/unit/cli/test_install_v2.py +++ b/tests/unit/cli/test_install_v2.py @@ -3,7 +3,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest import yaml from devsync.cli.install_v2 import _get_tool_instruction_path, _resolve_source, install_v2_command @@ -47,7 +46,9 @@ def test_install_no_manifest(self, tmp_path: Path) -> None: @patch("devsync.cli.install_v2.find_project_root") @patch("devsync.cli.install_v2._resolve_tools", return_value=["claude"]) @patch("devsync.cli.install_v2.Confirm.ask", return_value=False) - def test_install_v1_package_file_copy(self, mock_confirm: MagicMock, mock_tools: MagicMock, mock_root: MagicMock, tmp_path: Path) -> None: + def test_install_v1_package_file_copy( + self, mock_confirm: MagicMock, mock_tools: MagicMock, mock_root: MagicMock, tmp_path: Path + ) -> None: project_dir = tmp_path / "project" project_dir.mkdir() mock_root.return_value = project_dir diff --git a/tests/unit/cli/test_list_v2.py b/tests/unit/cli/test_list_v2.py index e9de6f3..42966c2 100644 --- a/tests/unit/cli/test_list_v2.py +++ b/tests/unit/cli/test_list_v2.py @@ -1,10 +1,7 @@ """Tests for v2 list command.""" -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from devsync.cli.list_v2 import list_v2_command @@ -12,14 +9,14 @@ class TestListV2Command: @patch("devsync.cli.list_v2.find_project_root", return_value=None) @patch("devsync.cli.list_v2.PackageTracker") def test_no_packages(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: - mock_tracker_cls.return_value.list_packages.return_value = [] + mock_tracker_cls.return_value.get_installed_packages.return_value = [] result = list_v2_command() assert result == 0 @patch("devsync.cli.list_v2.find_project_root", return_value=None) @patch("devsync.cli.list_v2.PackageTracker") def test_no_packages_json(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: - mock_tracker_cls.return_value.list_packages.return_value = [] + mock_tracker_cls.return_value.get_installed_packages.return_value = [] result = list_v2_command(json=True) assert result == 0 @@ -31,7 +28,7 @@ def test_with_packages(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) mock_pkg.version = "1.0.0" mock_pkg.components = [] mock_pkg.status = "COMPLETE" - mock_tracker_cls.return_value.list_packages.return_value = [mock_pkg] + mock_tracker_cls.return_value.get_installed_packages.return_value = [mock_pkg] result = list_v2_command() assert result == 0 @@ -39,6 +36,6 @@ def test_with_packages(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) @patch("devsync.cli.list_v2.find_project_root", return_value=None) @patch("devsync.cli.list_v2.PackageTracker") def test_tracker_exception(self, mock_tracker_cls: MagicMock, mock_root: MagicMock) -> None: - mock_tracker_cls.return_value.list_packages.side_effect = Exception("No file") + mock_tracker_cls.return_value.get_installed_packages.side_effect = Exception("No file") result = list_v2_command() assert result == 0 diff --git a/tests/unit/core/test_package_manifest_v2.py b/tests/unit/core/test_package_manifest_v2.py index c8b3a53..98c3884 100644 --- a/tests/unit/core/test_package_manifest_v2.py +++ b/tests/unit/core/test_package_manifest_v2.py @@ -100,11 +100,7 @@ def test_parse_v2_manifest(self, tmp_path: Path) -> None: "credentials": [{"name": "GITHUB_TOKEN", "description": "PAT"}], } ], - "components": { - "instructions": [ - {"name": "style", "file": "instructions/style.md", "tags": ["python"]} - ] - }, + "components": {"instructions": [{"name": "style", "file": "instructions/style.md", "tags": ["python"]}]}, } (tmp_path / "devsync-package.yaml").write_text(yaml.dump(manifest)) @@ -127,9 +123,7 @@ def test_parse_v1_manifest(self, tmp_path: Path) -> None: "license": "MIT", "namespace": "org/repo", "components": { - "instructions": [ - {"name": "rules", "file": "instructions/rules.md", "description": "Rules"} - ], + "instructions": [{"name": "rules", "file": "instructions/rules.md", "description": "Rules"}], "mcp_servers": [ { "name": "db", @@ -167,16 +161,10 @@ def test_parse_hybrid_v2_package(self, tmp_path: Path) -> None: "name": "hybrid", "version": "1.0.0", "description": "Hybrid package", - "practices": [ - {"name": "testing", "intent": "Ensure test coverage", "tags": ["testing"]} - ], + "practices": [{"name": "testing", "intent": "Ensure test coverage", "tags": ["testing"]}], "components": { - "instructions": [ - {"name": "testing-rules", "file": "instructions/testing.md"} - ], - "hooks": [ - {"name": "pre-commit", "file": "hooks/pre-commit.sh", "hook_type": "pre-commit"} - ], + "instructions": [{"name": "testing-rules", "file": "instructions/testing.md"}], + "hooks": [{"name": "pre-commit", "file": "hooks/pre-commit.sh", "hook_type": "pre-commit"}], }, } (tmp_path / "devsync-package.yaml").write_text(yaml.dump(manifest)) From 8b2d2a0da6e4ea91970303bbfcd936994873996d Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:07:49 -0500 Subject: [PATCH 10/14] fix(cli): fix tool filter logic bug and add path traversal protection (#63) Fix _package_has_tool returning True unconditionally (tool filter was a no-op). Add path traversal checks for manifest ref.file paths and instruction_name values to prevent reading/writing outside intended directories. --- devsync/cli/extract.py | 5 ++++- devsync/cli/install_v2.py | 7 ++++++- devsync/cli/list_v2.py | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index 740920b..a125567 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -148,7 +148,10 @@ def _upgrade_v1_package( if comp_type != "instructions": continue for ref in refs: - src_file = package_path / ref.file + src_file = (package_path / ref.file).resolve() + if not str(src_file).startswith(str(package_path.resolve())): + console.print(f" [red]Rejected (path traversal): {ref.file}[/red]") + continue if src_file.exists() and src_file.stat().st_size < 100_000: try: content = src_file.read_text(encoding="utf-8") diff --git a/devsync/cli/install_v2.py b/devsync/cli/install_v2.py index ec3c2a6..9c9dfd6 100644 --- a/devsync/cli/install_v2.py +++ b/devsync/cli/install_v2.py @@ -152,7 +152,10 @@ def _install_v2_fallback( if component_type != "instructions": continue for ref in refs: - src_file = package_path / ref.file + src_file = (package_path / ref.file).resolve() + if not str(src_file).startswith(str(package_path.resolve())): + console.print(f" [red]Rejected (path traversal): {ref.file}[/red]") + continue if not src_file.exists(): console.print(f" [yellow]Missing: {ref.file}[/yellow]") continue @@ -203,6 +206,8 @@ def _execute_plan(plan: AdaptationPlan, project_root: Path, target_tools: list[s def _get_tool_instruction_path(tool_name: str, project_root: Path, instruction_name: str) -> Optional[Path]: """Get the file path for an instruction in a specific tool.""" + if ".." in instruction_name or "/" in instruction_name or "\\" in instruction_name: + return None tool_paths: dict[str, tuple[str, str]] = { "claude": (".claude/rules", ".md"), "cursor": (".cursor/rules", ".mdc"), diff --git a/devsync/cli/list_v2.py b/devsync/cli/list_v2.py index cc26f0e..b6528b7 100644 --- a/devsync/cli/list_v2.py +++ b/devsync/cli/list_v2.py @@ -89,4 +89,4 @@ def _package_has_tool(pkg: object, tool_name: str) -> bool: comp_tool = getattr(comp, "ai_tool", None) or getattr(comp, "tool", None) if comp_tool and str(comp_tool).lower() == tool_name.lower(): return True - return True + return False From f896f12d704ebda72b0a0bf8d5ed12e6b084d755 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:43:20 -0500 Subject: [PATCH 11/14] fix(cli): resolve lint, format, and type errors in v2 modules (#63) --- devsync/cli/setup.py | 5 ++--- devsync/llm/anthropic.py | 1 - devsync/llm/config.py | 3 +-- devsync/llm/response_models.py | 1 - tests/unit/cli/test_extract.py | 1 - tests/unit/cli/test_setup.py | 5 +---- tests/unit/core/test_adapter.py | 2 -- tests/unit/core/test_extractor.py | 2 -- tests/unit/core/test_mcp_credential_prompter.py | 3 --- tests/unit/llm/test_config.py | 2 -- tests/unit/llm/test_openrouter.py | 3 --- tests/unit/llm/test_provider.py | 4 +--- tests/unit/llm/test_response_models.py | 3 +-- 13 files changed, 6 insertions(+), 29 deletions(-) diff --git a/devsync/cli/setup.py b/devsync/cli/setup.py index e55bbcc..55672bb 100644 --- a/devsync/cli/setup.py +++ b/devsync/cli/setup.py @@ -1,6 +1,5 @@ """Setup command for configuring LLM provider.""" -import typer from rich.console import Console from rich.prompt import Confirm, Prompt @@ -42,9 +41,9 @@ def setup_command() -> int: env_var = _PROVIDER_ENV_VARS[provider_name] default_model = _PROVIDER_DEFAULTS[provider_name] - console.print(f"\nSet your API key as an environment variable:") + console.print("\nSet your API key as an environment variable:") console.print(f" [cyan]export {env_var}=your-key-here[/cyan]") - console.print(f"\nAdd this to your shell profile (~/.zshrc, ~/.bashrc) for persistence.\n") + console.print("\nAdd this to your shell profile (~/.zshrc, ~/.bashrc) for persistence.\n") model = Prompt.ask("Model", default=default_model) diff --git a/devsync/llm/anthropic.py b/devsync/llm/anthropic.py index b9257be..0383c04 100644 --- a/devsync/llm/anthropic.py +++ b/devsync/llm/anthropic.py @@ -1,6 +1,5 @@ """Anthropic Claude provider using HTTP-only calls.""" -import json from typing import Optional import httpx diff --git a/devsync/llm/config.py b/devsync/llm/config.py index d71952a..adf6862 100644 --- a/devsync/llm/config.py +++ b/devsync/llm/config.py @@ -4,13 +4,12 @@ API keys are NEVER stored — only env var names for reference. """ -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Optional import yaml - _CONFIG_DIR = Path.home() / ".devsync" _CONFIG_FILE = _CONFIG_DIR / "config.yaml" diff --git a/devsync/llm/response_models.py b/devsync/llm/response_models.py index b478afd..ac65d1c 100644 --- a/devsync/llm/response_models.py +++ b/devsync/llm/response_models.py @@ -2,7 +2,6 @@ import json from dataclasses import dataclass, field -from typing import Optional from devsync.core.practice import MCPDeclaration, PracticeDeclaration diff --git a/tests/unit/cli/test_extract.py b/tests/unit/cli/test_extract.py index bde8813..304c537 100644 --- a/tests/unit/cli/test_extract.py +++ b/tests/unit/cli/test_extract.py @@ -3,7 +3,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest import yaml from devsync.cli.extract import _upgrade_v1_package, extract_command diff --git a/tests/unit/cli/test_setup.py b/tests/unit/cli/test_setup.py index 4b5e7bd..a3c04a4 100644 --- a/tests/unit/cli/test_setup.py +++ b/tests/unit/cli/test_setup.py @@ -1,12 +1,9 @@ """Tests for the setup command.""" -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from devsync.cli.setup import setup_command -from devsync.llm.config import LLMConfig, load_config +from devsync.llm.config import LLMConfig class TestSetupCommand: diff --git a/tests/unit/core/test_adapter.py b/tests/unit/core/test_adapter.py index 65c383b..d81c9e3 100644 --- a/tests/unit/core/test_adapter.py +++ b/tests/unit/core/test_adapter.py @@ -4,8 +4,6 @@ from pathlib import Path from unittest.mock import MagicMock -import pytest - from devsync.core.adapter import PracticeAdapter from devsync.core.practice import PracticeDeclaration from devsync.llm.provider import LLMProviderError, LLMResponse diff --git a/tests/unit/core/test_extractor.py b/tests/unit/core/test_extractor.py index 0f1282b..00a909c 100644 --- a/tests/unit/core/test_extractor.py +++ b/tests/unit/core/test_extractor.py @@ -4,8 +4,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from devsync.core.extractor import PracticeExtractor from devsync.llm.provider import LLMProviderError, LLMResponse diff --git a/tests/unit/core/test_mcp_credential_prompter.py b/tests/unit/core/test_mcp_credential_prompter.py index 1997720..74c7e2d 100644 --- a/tests/unit/core/test_mcp_credential_prompter.py +++ b/tests/unit/core/test_mcp_credential_prompter.py @@ -1,10 +1,7 @@ """Tests for MCP credential prompting.""" -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest - from devsync.core.mcp_credential_prompter import build_mcp_config, prompt_mcp_credentials from devsync.core.practice import CredentialSpec, MCPDeclaration diff --git a/tests/unit/llm/test_config.py b/tests/unit/llm/test_config.py index 22eff45..9e949be 100644 --- a/tests/unit/llm/test_config.py +++ b/tests/unit/llm/test_config.py @@ -2,8 +2,6 @@ from pathlib import Path -import pytest - from devsync.llm.config import LLMConfig, load_config, save_config diff --git a/tests/unit/llm/test_openrouter.py b/tests/unit/llm/test_openrouter.py index d2e32a7..06ac22d 100644 --- a/tests/unit/llm/test_openrouter.py +++ b/tests/unit/llm/test_openrouter.py @@ -2,10 +2,7 @@ from unittest.mock import MagicMock, patch -import pytest - from devsync.llm.openrouter import OpenRouterProvider -from devsync.llm.provider import LLMProviderError def _mock_response(status_code: int, json_data: dict) -> MagicMock: diff --git a/tests/unit/llm/test_provider.py b/tests/unit/llm/test_provider.py index 8a15a15..abdde5a 100644 --- a/tests/unit/llm/test_provider.py +++ b/tests/unit/llm/test_provider.py @@ -3,9 +3,7 @@ import os from unittest.mock import patch -import pytest - -from devsync.llm.provider import LLMProvider, LLMProviderError, LLMResponse, resolve_provider +from devsync.llm.provider import LLMProviderError, LLMResponse, resolve_provider class TestLLMResponse: diff --git a/tests/unit/llm/test_response_models.py b/tests/unit/llm/test_response_models.py index d2c68e2..044bf6a 100644 --- a/tests/unit/llm/test_response_models.py +++ b/tests/unit/llm/test_response_models.py @@ -4,16 +4,15 @@ import pytest +from devsync.core.practice import PracticeDeclaration from devsync.llm.response_models import ( AdaptationAction, AdaptationPlan, ExtractionResult, - MergeDecision, parse_adaptation_response, parse_extraction_response, parse_merge_response, ) -from devsync.core.practice import MCPDeclaration, PracticeDeclaration class TestExtractionResult: From aad90b1207888af6e4b11601eacc4515362c2e5f Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:43:27 -0500 Subject: [PATCH 12/14] docs: update ReadTheDocs for v2 AI-powered architecture (#63) --- docs/advanced/config-types.md | 18 +- docs/advanced/conflict-resolution.md | 49 +- docs/advanced/contributing.md | 15 +- docs/advanced/project-root-detection.md | 12 +- docs/cli/delete.md | 60 -- docs/cli/download.md | 85 --- docs/cli/extract.md | 113 +++ docs/cli/index.md | 81 +-- docs/cli/install.md | 163 +++-- docs/cli/list.md | 158 +---- docs/cli/package.md | 244 ------- docs/cli/setup.md | 69 ++ docs/cli/uninstall.md | 26 +- docs/cli/update.md | 66 -- docs/getting-started/concepts.md | 153 ++--- docs/getting-started/installation.md | 14 +- docs/getting-started/quickstart.md | 126 ++-- docs/ide-integrations/claude-code.md | 18 +- docs/ide-integrations/codex.md | 26 +- docs/ide-integrations/copilot.md | 23 +- docs/ide-integrations/cursor.md | 23 +- docs/ide-integrations/index.md | 4 +- docs/ide-integrations/other-ides.md | 39 +- docs/ide-integrations/windsurf.md | 25 +- docs/index.md | 62 +- docs/mcp-server/ai-merge-flow.md | 133 ---- docs/mcp-server/backups-and-recovery.md | 197 ------ docs/mcp-server/ide-setup.md | 407 ----------- docs/mcp-server/index.md | 89 --- docs/mcp-server/installation.md | 303 --------- docs/mcp-server/team-profiles.md | 167 ----- docs/mcp-server/tools-reference.md | 193 ------ docs/packages/components.md | 436 ++---------- docs/packages/creating.md | 430 ++++-------- docs/packages/examples.md | 700 ++++--------------- docs/packages/index.md | 129 ++-- docs/packages/installing.md | 342 +++------- docs/reference/cli-reference.md | 499 ++------------ docs/reference/yaml-schemas.md | 257 ++++--- docs/tutorials/ai-merge-workflow.md | 268 -------- docs/tutorials/ci-cd-integration.md | 324 --------- docs/tutorials/custom-packages.md | 756 +++------------------ docs/tutorials/migrate-existing-configs.md | 410 +++-------- docs/tutorials/multi-ide-workflow.md | 268 ++------ docs/tutorials/onboard-new-developer.md | 451 ++---------- docs/tutorials/team-config-repo.md | 473 ++----------- mkdocs.yml | 16 +- 47 files changed, 1764 insertions(+), 7156 deletions(-) delete mode 100644 docs/cli/delete.md delete mode 100644 docs/cli/download.md create mode 100644 docs/cli/extract.md delete mode 100644 docs/cli/package.md create mode 100644 docs/cli/setup.md delete mode 100644 docs/cli/update.md delete mode 100644 docs/mcp-server/ai-merge-flow.md delete mode 100644 docs/mcp-server/backups-and-recovery.md delete mode 100644 docs/mcp-server/ide-setup.md delete mode 100644 docs/mcp-server/index.md delete mode 100644 docs/mcp-server/installation.md delete mode 100644 docs/mcp-server/team-profiles.md delete mode 100644 docs/mcp-server/tools-reference.md delete mode 100644 docs/tutorials/ai-merge-workflow.md delete mode 100644 docs/tutorials/ci-cd-integration.md diff --git a/docs/advanced/config-types.md b/docs/advanced/config-types.md index 08bb5db..787dafd 100644 --- a/docs/advanced/config-types.md +++ b/docs/advanced/config-types.md @@ -1,12 +1,20 @@ # Configuration Types and Detection -DevSync manages several distinct configuration types across 22 supported AI coding tools. Each type has specific file formats, installation patterns, and detection rules. +DevSync manages several distinct configuration types across 23+ supported AI coding tools. Each type has specific file formats, installation patterns, and detection rules. ## Configuration Types -### Instructions +### Practices -Instructions are the core configuration type -- markdown files that guide AI coding assistants. DevSync supports two installation patterns depending on the target IDE. +Practices are the primary configuration type in v2. They are abstract, tool-agnostic declarations of development standards that the LLM reads and adapts into IDE-specific rule files. Practices are defined in the `practices` section of a `devsync-package.yaml` manifest and include structured fields: intent, principles, enforcement patterns, and examples. + +During installation, the AI adaptation engine reads each practice and generates the appropriate content for each target IDE -- formatting it as a `.mdc` file for Cursor, a `.md` file for Claude Code, a section in `AGENTS.md` for Codex, etc. + +### Instructions (v1) + +In v1, instructions were explicit markdown files distributed directly to IDE rule directories. In v2, the equivalent is the `practices` section in the package manifest, which the LLM uses to generate IDE-adapted content. File-copy fallback (using `--no-ai`) still writes instructions as-is to their target locations. + +DevSync supports two installation patterns for instructions depending on the target IDE. #### Multi-File Pattern @@ -172,7 +180,7 @@ Each resource is checksummed with SHA-256 for integrity verification. ## Component Detection -The `ComponentDetector` class in `devsync/core/component_detector.py` scans a project directory to discover all existing configurations. This is used by `devsync package create` to auto-detect components when generating a package from an existing project. +The `ComponentDetector` class in `devsync/core/component_detector.py` scans a project directory to discover all existing configurations. This is used by `devsync extract` to auto-detect components when generating a package from an existing project. ### How Detection Works @@ -234,4 +242,4 @@ components = detector.to_package_components(result) # components.instructions, components.mcp_servers, etc. ``` -This conversion is used internally by `devsync package create` to build the `ai-config-kit-package.yaml` manifest. +This conversion is used internally by `devsync extract` to build the `devsync-package.yaml` manifest. diff --git a/docs/advanced/conflict-resolution.md b/docs/advanced/conflict-resolution.md index 64daad9..9f0995b 100644 --- a/docs/advanced/conflict-resolution.md +++ b/docs/advanced/conflict-resolution.md @@ -1,13 +1,14 @@ # Conflict Resolution -When installing instructions or templates, DevSync checks whether the target file already exists. If it does, a conflict must be resolved before installation can proceed. +In v2, DevSync uses AI-powered merging as the primary approach to conflict resolution. When a practice or instruction already exists in a target IDE's rule directory, the LLM reads both the incoming content and the existing content and produces a semantically merged result, preserving any local customizations while incorporating the new standards. + +File-level strategies (skip, overwrite, rename) are still available as fallback modes -- used when running with `--no-ai`, when the LLM is unavailable, or when explicitly requested via the `--conflict` flag. ## When Conflicts Occur A conflict is detected when: - An instruction file already exists at the target path (e.g., `.cursor/rules/python-style.mdc` already exists) -- A template has been modified locally since it was last installed - A section with the same name already exists in a single-file IDE (e.g., an `AGENTS.md` section with the same marker) - A package component would overwrite an existing file @@ -15,9 +16,13 @@ A conflict is detected when: DevSync provides four conflict resolution strategies. -### Prompt (Default) +### AI Merge (Default in v2) + +When an LLM provider is configured, the default behavior is to let the AI merge incoming practice content with the existing rule file. The LLM reads both versions and produces a unified result that preserves local customizations while incorporating the new standards. -The default strategy for interactive use. When a conflict is detected, DevSync displays a Rich terminal prompt asking the user to choose how to proceed. +To configure an LLM provider, run `devsync setup` before installing packages. + +For non-AI fallback, DevSync displays a prompt: ``` Conflict: Instruction 'python-style' already exists. @@ -29,8 +34,6 @@ How would you like to resolve this? Your choice [k/o/r]: ``` -For template conflicts, the prompt also displays context about the conflict type (local-only changes vs. both sides changed). - ### Skip Keeps the existing file unchanged. The new instruction is not installed. @@ -91,20 +94,12 @@ devsync install python-style --conflict overwrite devsync install python-style --conflict rename ``` -### Templates - -Use the `--conflict` flag with `devsync template install`: - -```bash -devsync template install my-repo/security-rules --conflict overwrite -``` - ### Packages -Use the `--conflict` (or `-c`) flag with `devsync package install`: +Use the `--conflict` (or `-c`) flag with `devsync install`: ```bash -devsync package install ./my-package --ide claude --conflict overwrite +devsync install ./my-package --tool claude --conflict overwrite ``` ## Upgrade Detection @@ -167,36 +162,22 @@ All checksums use SHA-256 for integrity verification. ## Batch Conflict Resolution -When installing multiple instructions at once (e.g., a bundle or package), DevSync can apply the same strategy to all conflicts: +When installing a package with multiple practices, DevSync can apply the same strategy to all conflicts: ```bash # Skip all conflicts in a batch install -devsync install python-style testing-guide api-design --conflict skip +devsync install ./team-standards --conflict skip # Overwrite all existing files -devsync install --bundle python-backend --conflict overwrite +devsync install ./team-standards --conflict overwrite ``` The `batch_resolve_conflicts()` function applies the chosen strategy uniformly to all detected conflicts in a single operation. ## Backup Integration -Backups are created automatically before any destructive operation during template conflict resolution. The backup system: +Backups are created automatically before any destructive overwrite operation. The backup system: - Stores backups in `.devsync/backups//` - Preserves the original filename - Handles name collisions within the same timestamp directory by appending a counter suffix -- Supports listing, restoring, and cleaning up old backups - -```bash -# List available backups -devsync template backup list - -# Restore a specific backup -devsync template backup restore - -# Clean up backups older than 30 days -devsync template backup cleanup --days 30 -``` - -See the [Template Backup commands](../reference/cli-reference.md) for the full CLI reference. diff --git a/docs/advanced/contributing.md b/docs/advanced/contributing.md index bb277bf..c0aa81c 100644 --- a/docs/advanced/contributing.md +++ b/docs/advanced/contributing.md @@ -128,7 +128,7 @@ type(scope): description ### Scope -Use the module name: `cli`, `core`, `storage`, `tui`, `ai_tools`, `utils`. +Use the module name: `cli`, `core`, `storage`, `ai_tools`, `llm`, `utils`. ### Examples @@ -236,6 +236,19 @@ chmod +x .githooks/pre-push ## Adding a New CLI Command +The v2 CLI has six commands: `setup`, `tools`, `extract`, `install`, `list`, `uninstall`. These are defined in the following files: + +| Command | File | +|---------|------| +| `setup` | `devsync/cli/setup.py` | +| `tools` | `devsync/cli/tools.py` | +| `extract` | `devsync/cli/extract.py` | +| `install` | `devsync/cli/install_v2.py` | +| `list` | `devsync/cli/list_v2.py` | +| `uninstall` | `devsync/cli/uninstall.py` | + +To add a new command: + 1. Create a command file in `devsync/cli/` (e.g., `devsync/cli/my_command.py`) 2. Define the command function with Typer decorators 3. Register in `devsync/cli/main.py` diff --git a/docs/advanced/project-root-detection.md b/docs/advanced/project-root-detection.md index 15f2f41..dc99639 100644 --- a/docs/advanced/project-root-detection.md +++ b/docs/advanced/project-root-detection.md @@ -43,9 +43,7 @@ Project root detection affects several aspects of DevSync: **Tracking data.** Installation records are stored in `/.devsync/installations.json`. The `installed_path` field in each record uses relative paths so that tracking files are portable across machines and safe to commit to version control. -**Package operations.** The `devsync package install` and `devsync package list` commands use the project root to locate `.devsync/packages.json`. - -**Backup storage.** Template backups are stored under `/.devsync/backups/`. +**Package operations.** The `devsync install` and `devsync list` commands use the project root to locate `.devsync/packages.json`. ## Running from Subdirectories @@ -53,16 +51,16 @@ You can run `devsync` commands from any subdirectory within a project. The upwar ```bash cd ~/projects/my-app/src/components -devsync install python-style # installs to ~/projects/my-app/.claude/rules/python-style.md +devsync install ./team-standards # installs to ~/projects/my-app/.claude/rules/... ``` ## Overriding the Project Root -Some commands accept a `--project` flag to explicitly specify the project root, bypassing auto-detection: +Some commands accept a `--project-dir` flag to explicitly specify the project root, bypassing auto-detection: ```bash -devsync package install ./my-package --project ~/projects/my-app -devsync package list --project ~/projects/my-app +devsync install ./my-package --project-dir ~/projects/my-app +devsync extract --project-dir ~/projects/my-app --output ./pkg --name my-pkg ``` ## Edge Cases diff --git a/docs/cli/delete.md b/docs/cli/delete.md deleted file mode 100644 index de120a2..0000000 --- a/docs/cli/delete.md +++ /dev/null @@ -1,60 +0,0 @@ -# devsync delete - -Remove a downloaded source from your local library. - -## Usage - -``` -$ devsync delete [options] -``` - -## Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `namespace` | Repository namespace to delete | Yes | - -## Options - -| Option | Short | Description | -|--------|-------|-------------| -| `--force` | `-f` | Skip the confirmation prompt | - -## How It Works - -The `delete` command removes a source and its instructions from `~/.devsync/library/`. It does **not** uninstall instructions that have already been installed into your AI tools. - -!!! warning - Deleting a source from the library does not remove instruction files from your AI tools. Use `devsync uninstall` to remove installed instructions. - -If any instructions from the source are currently installed, DevSync shows a warning listing them before prompting for confirmation. - -## Examples - -### Delete a source - -``` -$ devsync delete github.com_company_instructions -``` - -You will be prompted to confirm the deletion. - -### Delete without confirmation - -``` -$ devsync delete github.com_company_instructions --force -``` - -### Find the namespace first - -``` -$ devsync list library -$ devsync delete github.com_company_instructions -``` - -## After Deletion - -- The source and its instructions are removed from `~/.devsync/library/` -- Previously installed instructions remain in your AI tool directories -- You can re-download the source at any time with `devsync download` -- To remove installed instructions, use `devsync uninstall ` diff --git a/docs/cli/download.md b/docs/cli/download.md deleted file mode 100644 index dc1f8a8..0000000 --- a/docs/cli/download.md +++ /dev/null @@ -1,85 +0,0 @@ -# devsync download - -Download instructions from a source into your local library at `~/.devsync/library/`. - -## Usage - -``` -$ devsync download --from [options] -``` - -## Options - -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `--from` | `-f` | Source URL or local directory path | Yes | -| `--ref` | `-r` | Git reference (tag, branch, or commit) | No | -| `--as` | `-a` | Custom alias for this source | No | -| `--force` | | Re-download even if already in library | No | - -## How It Works - -The `download` command clones or copies instruction repositories into `~/.devsync/library/`, organized by namespace. Instructions are cached locally but not installed to any AI tool. Use `devsync install` after downloading to install instructions into your project. - -Each downloaded source gets a namespace (auto-generated from the URL) and an optional alias for easier reference. - -!!! tip - Download first, then install. This two-step workflow lets you browse and selectively install instructions without re-cloning every time. - -## Examples - -### Download from a Git URL - -``` -$ devsync download --from https://github.com/company/ai-instructions -``` - -### Download from a local folder - -``` -$ devsync download --from ./my-instructions -``` - -!!! warning - The `--ref` option is not supported for local directories. Local sources are copied as-is. - -### Download a specific tag - -``` -$ devsync download --from https://github.com/company/ai-instructions --ref v1.0.0 -``` - -Tags and commits are treated as immutable. They will not be updated by `devsync update`. - -### Download from a specific branch - -``` -$ devsync download --from https://github.com/company/ai-instructions --ref develop -``` - -Branch-based downloads can be updated later with `devsync update`. - -### Download with a custom alias - -``` -$ devsync download --from https://github.com/company/ai-instructions --as company -``` - -The alias makes it easier to reference the source when listing or installing instructions. - -### Force re-download - -``` -$ devsync download --from https://github.com/company/ai-instructions --force -``` - -Use `--force` to overwrite a source that already exists in your library. - -## What Happens Next - -After downloading, you can: - -- Browse your library: `devsync list library` -- View individual instructions: `devsync list library --instructions` -- Install interactively: `devsync install` -- Install by name: `devsync install python-best-practices` diff --git a/docs/cli/extract.md b/docs/cli/extract.md new file mode 100644 index 0000000..af1b53b --- /dev/null +++ b/docs/cli/extract.md @@ -0,0 +1,113 @@ +# devsync extract + +Extract practices from a project into a shareable package. DevSync reads the project's existing AI rules, MCP configurations, and commands, then produces abstract practice declarations that can be installed into any project. + +## Usage + +``` +$ devsync extract [OPTIONS] +``` + +## Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--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 | -- | + +## AI-Powered Mode (Default) + +With an LLM configured (`devsync setup`), extraction reads rule files and produces abstract practice declarations that capture the _intent_ behind each rule: + +```bash +$ devsync extract --output ./team-standards --name team-standards +``` + +``` +Extracting practices from /home/user/my-project... + + Scanning: .claude/rules/ (3 files) + Scanning: .cursor/rules/ (2 files) + Scanning: MCP configurations (1 server) + + 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 + + Extracted 1 MCP server: + - github: GitHub API access + +Package written to: ./team-standards/devsync-package.yaml +``` + +The output directory contains: + +``` +team-standards/ +├── devsync-package.yaml # Package manifest with practices +├── practices/ # Practice declaration files +│ ├── type-safety.md +│ ├── error-handling.md +│ ├── code-style.md +│ └── testing.md +└── mcp/ # MCP server configs + └── github.json +``` + +## File-Copy Mode + +Use `--no-ai` when you don't have an LLM configured or want exact file copies: + +```bash +$ devsync extract --output ./team-standards --name team-standards --no-ai +``` + +In file-copy mode, source files are copied verbatim instead of being processed by an LLM. The resulting package works with `devsync install --no-ai`. + +## Upgrading v1 Packages + +Convert an existing v1 package (`ai-config-kit-package.yaml`) to v2 format: + +```bash +$ devsync extract --upgrade ./old-v1-package --output ./v2-package --name my-package +``` + +This reads the v1 manifest and its component files, extracts practice declarations from the instructions, and produces a v2 package. + +## Examples + +### Extract from current directory + +```bash +$ devsync extract --name my-project-standards +``` + +### Extract from a different project + +```bash +$ devsync extract --project-dir ~/other-project --output ./other-standards --name other-standards +``` + +### Extract to a specific output location + +```bash +$ devsync extract --output ~/shared/team-standards --name team-standards +``` + +## What Gets Extracted + +DevSync scans for: + +| Source | Locations | +|--------|----------| +| 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` | + +!!! 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/docs/cli/index.md b/docs/cli/index.md index 2b87d8a..0e06317 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -10,75 +10,49 @@ $ devsync [options] ## Commands -### Core Workflow - | Command | Description | |---------|-------------| -| [`download`](download.md) | Download instruction repositories to your local library | -| [`install`](install.md) | Install instructions to AI tools (interactive TUI or named) | -| [`uninstall`](uninstall.md) | Remove instructions from AI tools at project level | -| [`update`](update.md) | Update downloaded instructions to latest versions | -| [`delete`](delete.md) | Remove a source from your local library | - -### Listing & Discovery +| [`setup`](setup.md) | Configure LLM provider (one-time) | +| [`tools`](tools.md) | Detect installed AI coding tools | +| [`extract`](extract.md) | Extract practices from a project | +| [`install`](install.md) | Install a package with AI adaptation | +| [`list`](list.md) | Show installed packages | +| [`uninstall`](uninstall.md) | Remove an installed package | -| Command | Description | -|---------|-------------| -| [`list available`](list.md#list-available) | List instructions from a remote source without downloading | -| [`list installed`](list.md#list-installed) | List instructions installed in your AI tools | -| [`list library`](list.md#list-library) | List sources and instructions in your local library | +## Typical Workflow -### Template Management +```bash +# 1. One-time setup +devsync setup -| Command | Description | -|---------|-------------| -| `template init` | Create a new template repository | -| `template install` | Install a template repository | -| `template list` | List installed templates | -| `template update` | Update installed templates | -| `template uninstall` | Uninstall a template repository | -| `template validate` | Validate a template repository structure | -| `template backup list` | List template backups | -| `template backup cleanup` | Clean up old template backups | -| `template backup restore` | Restore a template from backup | - -### MCP Server Management +# 2. In your source project, extract practices +cd ~/team-project +devsync extract --output ./team-standards --name team-standards -| Command | Description | -|---------|-------------| -| `mcp install` | Install MCP server configurations | -| `mcp configure` | Configure credentials for MCP servers | -| `mcp sync` | Sync MCP servers to AI tool configuration files | +# 3. In your target project, install +cd ~/new-project +devsync install ~/team-project/team-standards -### Package Management +# 4. Check what's installed +devsync list -| Command | Description | -|---------|-------------| -| [`package install`](package.md#package-install) | Install a configuration package to a project | -| [`package list`](package.md#package-list) | List installed packages | -| [`package uninstall`](package.md#package-uninstall) | Remove a package from a project | -| [`package create`](package.md#package-create) | Create a shareable package from project components | - -### Utilities - -| Command | Description | -|---------|-------------| -| [`tools`](tools.md) | Show detected AI coding tools | -| `version` | Show DevSync version | +# 5. Remove if needed +devsync uninstall team-standards +``` ## Global Options ``` $ devsync --help # Show help -$ devsync --version # Show version (via `devsync version`) +$ devsync --version # Show version ``` Every command supports `--help` for detailed usage: ``` -$ devsync download --help -$ devsync list available --help -$ devsync package install --help +$ devsync setup --help +$ devsync extract --help +$ devsync install --help ``` ## Environment Variables @@ -86,7 +60,10 @@ $ devsync package install --help | Variable | Description | Default | |----------|-------------|---------| | `LOGLEVEL` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` | +| `ANTHROPIC_API_KEY` | Anthropic API key (auto-detected by `devsync setup`) | -- | +| `OPENAI_API_KEY` | OpenAI API key (auto-detected by `devsync setup`) | -- | +| `OPENROUTER_API_KEY` | OpenRouter API key (auto-detected by `devsync setup`) | -- | ``` -$ LOGLEVEL=DEBUG devsync install +$ LOGLEVEL=DEBUG devsync install ./package ``` diff --git a/docs/cli/install.md b/docs/cli/install.md index 9b459a9..c1f5511 100644 --- a/docs/cli/install.md +++ b/docs/cli/install.md @@ -1,117 +1,164 @@ # devsync install -Install instructions from your library or directly from a source into your AI coding tools. +Install a package into the current project. DevSync auto-detects your AI tools and adapts practices to your existing setup using AI. ## Usage ``` -$ devsync install [names...] [options] +$ devsync install [OPTIONS] ``` +## Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `source` | Package source: local directory path or Git URL | Yes | + ## Options -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `names` | | Instruction name(s) to install (positional, multiple allowed) | No | -| `--from` | `-f` | Source URL or path for direct install (bypasses library) | No | -| `--tool` | `-t` | AI tool(s) to install to (can specify multiple times) | No | -| `--conflict` | `-c` | Conflict resolution: `prompt`, `skip`, `rename`, `overwrite` | No (default: `prompt`) | -| `--bundle` | `-b` | Install as a bundle (group of instructions) | No | +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--tool` | `-t` | Target AI tool(s), repeatable. Auto-detects if not specified | -- | +| `--no-ai` | -- | Use file-copy mode instead of AI adaptation | `False` | +| `--conflict` | `-c` | Conflict resolution: `prompt`, `skip`, `rename`, `overwrite` | `prompt` | +| `--project-dir` | `-p` | Target project directory | `.` | -## Modes +## How It Works -### Interactive TUI Mode +1. **Parse package** -- reads `devsync-package.yaml` (v2) or `ai-config-kit-package.yaml` (v1) +2. **Detect tools** -- finds installed AI tools (or uses `--tool` filter) +3. **AI adaptation** -- uses LLM to merge practices with your existing rules +4. **Display plan** -- shows what will be created, merged, or skipped +5. **Install files** -- writes adapted files to tool-specific directories +6. **MCP credentials** -- prompts for any required MCP server credentials +7. **Track** -- records installation in `.devsync/packages.json` -Run `devsync install` with no arguments to open the interactive terminal UI. The TUI lets you browse your library, select instructions, and choose which AI tools to install to. +## Sources +### Local directory + +```bash +$ devsync install ./team-standards +$ devsync install ~/packages/security-compliance ``` -$ devsync install + +### Git URL + +```bash +$ devsync install https://github.com/company/team-standards ``` -!!! tip - The TUI requires instructions in your local library. Run `devsync download` first to populate it. +DevSync clones the repository and installs from it. -### Named Install from Library +## AI Adaptation -Specify one or more instruction names to install directly from your library: +With AI enabled (default), DevSync reads your existing rules and intelligently merges incoming practices: -``` -$ devsync install python-best-practices -``` +- **New practices** are written as new files +- **Overlapping practices** are merged with existing rules, avoiding duplication +- **Conflicting content** follows the chosen conflict strategy -If multiple sources contain an instruction with the same name, use the `source/name` format: +```bash +$ devsync install ./team-standards -``` -$ devsync install company/python-best-practices +Installing team-standards... + + Claude Code: + Created: .claude/rules/type-safety.md + Merged: .claude/rules/code-style.md (adapted to existing) + Created: .claude/rules/testing.md + + Cursor: + Created: .cursor/rules/type-safety.mdc + Merged: .cursor/rules/code-style.mdc (adapted to existing) + Created: .cursor/rules/testing.mdc ``` -### Direct Source Install +## File-Copy Mode -Use `--from` to install directly from a URL or path without downloading to your library first: +Use `--no-ai` to skip AI adaptation and copy files directly: +```bash +$ devsync install ./team-standards --no-ai ``` -$ devsync install python-style --from https://github.com/company/instructions + +This copies practice files verbatim to each tool's directory, converting file extensions as needed (e.g., `.md` to `.mdc` for Cursor). + +## Targeting Specific Tools + +By default, DevSync installs to all detected AI tools. Use `--tool` to target specific ones: + +```bash +# Install to Claude Code only +$ devsync install ./pkg --tool claude + +# Install to Claude Code and Cursor +$ devsync install ./pkg --tool claude --tool cursor ``` +Valid tool names: `claude`, `cursor`, `windsurf`, `copilot`, `kiro`, `cline`, `roo`, `codex`, `gemini`, and more. Run `devsync tools` for the full list. + ## Conflict Resolution -When an instruction with the same name already exists in the target tool: +When a file already exists at the target path: | Strategy | Behavior | |----------|----------| | `prompt` | Ask what to do (default) | -| `skip` | Keep existing file, do not install | -| `rename` | Install with a numeric suffix (e.g., `instruction-1.md`) | +| `skip` | Keep existing file, don't install | +| `rename` | Install with a numeric suffix (e.g., `rule-1.md`) | | `overwrite` | Replace the existing file | -## Examples - -### Install with interactive TUI - -``` -$ devsync install +```bash +$ devsync install ./pkg --conflict skip +$ devsync install ./pkg --conflict overwrite ``` -### Install a specific instruction +## MCP Credential Prompting + +If a package includes MCP server configurations that require credentials, DevSync prompts for them during installation: ``` -$ devsync install python-best-practices +MCP server "github" requires credentials: + + GITHUB_TOKEN (required): GitHub personal access token + > [enter value] ``` -### Install multiple instructions at once +Credentials are written to environment-specific locations (never to tracked files). -``` -$ devsync install python-style testing-guide api-design -``` +## v1 Package Compatibility -### Install to specific AI tools +DevSync automatically detects v1 packages (`ai-config-kit-package.yaml`) and installs them using file-copy mode. Components are filtered by IDE capability -- unsupported component types are skipped automatically. -``` -$ devsync install python-style --tool cursor --tool claude -``` +## Examples -### Install with conflict handling +### Install from a local package -``` -$ devsync install python-style --conflict overwrite +```bash +$ devsync install ./team-standards ``` -### Install directly from a source +### Install from Git -``` -$ devsync install python-style --from https://github.com/company/instructions +```bash +$ devsync install https://github.com/company/standards ``` -### Install a bundle +### Install to specific tools with conflict handling -``` -$ devsync install python-backend --bundle --from https://github.com/company/instructions +```bash +$ devsync install ./pkg --tool claude --tool cursor --conflict overwrite ``` -A bundle installs a group of related instructions defined in the source repository's `ai-config-kit.yaml`. +### Install without AI -## AI Tool Detection +```bash +$ devsync install ./pkg --no-ai +``` -If `--tool` is not specified, DevSync auto-detects installed AI tools and installs to the primary detected tool. Use `devsync tools` to see which tools are detected on your system. +### Install to a different project -All installations are project-scoped. Instruction files are written to the tool-specific directory within your project root (e.g., `.cursor/rules/`, `.claude/rules/`). +```bash +$ devsync install ./pkg --project-dir ~/other-project +``` diff --git a/docs/cli/list.md b/docs/cli/list.md index 50e3da3..1dabf09 100644 --- a/docs/cli/list.md +++ b/docs/cli/list.md @@ -1,152 +1,68 @@ # devsync list -List instructions from remote sources, your local library, or installed in your AI tools. +Show installed packages in the current project. ## Usage ``` -$ devsync list [options] +$ devsync list [OPTIONS] ``` ---- +## Options -## list available +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--tool` | `-t` | Filter by AI tool | -- | +| `--json` | -- | Output as JSON | `False` | -List instructions from a remote source without downloading. +## Examples -### Usage +### List all installed packages +```bash +$ devsync list ``` -$ devsync list available --from [options] -``` - -### Options - -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `--from` | `-f` | Source URL or local directory path | Yes | -| `--tag` | `-t` | Filter by tag | No | -| `--bundles-only` | | Show only bundles | No | -| `--instructions-only` | | Show only individual instructions | No | - -### Examples - -List all instructions from a repository: - -``` -$ devsync list available --from https://github.com/company/ai-instructions -``` - -List from a local directory: - -``` -$ devsync list available --from ./my-instructions -``` - -Filter by tag: - -``` -$ devsync list available --from https://github.com/company/ai-instructions --tag python -``` - -Show only bundles: - -``` -$ devsync list available --from https://github.com/company/ai-instructions --bundles-only -``` - -Show only individual instructions (no bundles): - -``` -$ devsync list available --from https://github.com/company/ai-instructions --instructions-only -``` - -!!! tip - Use `list available` to preview what a repository offers before downloading it with `devsync download`. - ---- - -## list installed - -List instructions currently installed in your AI tools at the project level. -### Usage - -``` -$ devsync list installed [options] ``` +Installed packages in /home/user/my-project: -### Options - -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `--tool` | `-t` | Filter by AI tool (`cursor`, `copilot`, `windsurf`, `claude`, etc.) | No | -| `--source` | `-s` | Filter by source alias or name | No | - -### Examples + team-standards v1.0.0 4 practices, 1 MCP server Claude Code, Cursor + security-rules v2.1.0 3 practices Claude Code -List all installed instructions: - -``` -$ devsync list installed +Total: 2 package(s) ``` -Filter by AI tool: +### Filter by tool -``` -$ devsync list installed --tool cursor +```bash +$ devsync list --tool claude ``` -Filter by source: +Shows only packages installed to Claude Code. -``` -$ devsync list installed --source company -``` - -Combine filters: +### JSON output +```bash +$ devsync list --json ``` -$ devsync list installed --tool claude --source company -``` - ---- - -## list library - -List sources and instructions stored in your local library (`~/.devsync/library/`). - -### Usage +```json +[ + { + "name": "team-standards", + "version": "1.0.0", + "tools": ["claude", "cursor"], + "components": { + "practices": 4, + "mcp_servers": 1 + } + } +] ``` -$ devsync list library [options] -``` - -### Options - -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `--source` | `-s` | Filter by source alias or namespace | No | -| `--instructions` | `-i` | Show individual instructions instead of repositories | No | - -### Examples - -List all downloaded sources: - -``` -$ devsync list library -``` - -Show individual instructions across all sources: -``` -$ devsync list library --instructions -``` +## Installation Tracking -Filter by source: - -``` -$ devsync list library --source company -``` +Installed packages are tracked in `.devsync/packages.json` at the project root. This file records package names, versions, installed components, and timestamps. DevSync uses this to manage installations and detect conflicts. !!! tip - The default view shows repository-level summaries. Use `--instructions` to see every individual instruction with its name, description, tags, and version. + Use `devsync list --json` for scripting and CI/CD integration. diff --git a/docs/cli/package.md b/docs/cli/package.md deleted file mode 100644 index 9742c90..0000000 --- a/docs/cli/package.md +++ /dev/null @@ -1,244 +0,0 @@ -# devsync package - -Manage multi-component configuration packages containing instructions, MCP servers, hooks, commands, and resources. - -## Usage - -``` -$ devsync package [options] -``` - -For details on the package format and manifest schema, see the [Packages documentation](../packages/index.md). - ---- - -## package install - -Install a configuration package to a project. - -### Usage - -``` -$ devsync package install --ide [options] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `path` | Path to package directory containing `ai-config-kit-package.yaml` | Yes | - -### Options - -| Option | Short | Description | Default | -|--------|-------|-------------|---------| -| `--ide` | `-i` | Target IDE (`claude`, `cursor`, `windsurf`, `copilot`, etc.) | `claude` | -| `--project` | `-p` | Project root directory | Current directory | -| `--conflict` | `-c` | Conflict strategy: `skip`, `overwrite`, `rename` | `skip` | -| `--force` | `-f` | Force reinstallation if already installed | `false` | -| `--quiet` | `-q` | Minimal output | `false` | -| `--json` | | Output results as JSON | `false` | - -### IDE Capabilities - -Different IDEs support different component types. Unsupported components are automatically skipped. - -| IDE | Instructions | MCP | Hooks | Commands | Resources | -|-----|:---:|:---:|:---:|:---:|:---:| -| Claude Code | Yes | Yes | Yes | Yes | Yes | -| Roo Code | Yes | Yes | No | Yes | Yes | -| Cline | Yes | No | No | No | Yes | -| Cursor | Yes | No | No | No | Yes | -| Kiro | Yes | No | No | No | Yes | -| Windsurf | Yes | No | No | No | Yes | -| Codex CLI | Yes | No | No | No | Yes | -| GitHub Copilot | Yes | No | No | No | No | - -### Examples - -Install a package for Claude Code: - -``` -$ devsync package install ./python-dev-setup --ide claude -``` - -Install for Cursor with conflict handling: - -``` -$ devsync package install ./python-dev-setup --ide cursor --conflict overwrite -``` - -Force reinstall: - -``` -$ devsync package install ./python-dev-setup --ide claude --force -``` - -Install to a specific project: - -``` -$ devsync package install ./python-dev-setup --ide claude --project ~/my-app -``` - -JSON output for scripting: - -``` -$ devsync package install ./python-dev-setup --ide claude --json -``` - -!!! tip - Use `--force` combined with `--conflict overwrite` to completely refresh a package installation. - ---- - -## package list - -List packages installed in a project. - -### Usage - -``` -$ devsync package list [options] -``` - -### Options - -| Option | Short | Description | Default | -|--------|-------|-------------|---------| -| `--project` | `-p` | Project root directory | Current directory | -| `--json` | | Output as JSON | `false` | - -### Examples - -List packages in the current project: - -``` -$ devsync package list -``` - -List packages in another project: - -``` -$ devsync package list --project ~/other-project -``` - -JSON output for scripting: - -``` -$ devsync package list --json -``` - -Count installed packages: - -``` -$ devsync package list --json | jq 'length' -``` - -### Output - -The table shows package name, version, status, component count, and installation date. Status indicators: - -- **complete** -- All components installed successfully -- **partial** -- Some components skipped (IDE filtering or conflicts) -- **failed** -- Installation encountered errors -- **pending_credentials** -- MCP servers need credential configuration - ---- - -## package uninstall - -Remove an installed package from a project. - -### Usage - -``` -$ devsync package uninstall [options] -``` - -### Arguments - -| Argument | Description | Required | -|----------|-------------|----------| -| `name` | Package name to uninstall | Yes | - -### Options - -| Option | Short | Description | Default | -|--------|-------|-------------|---------| -| `--project` | `-p` | Project root directory | Current directory | -| `--yes` | `-y` | Skip confirmation prompt | `false` | - -### Examples - -Uninstall with confirmation: - -``` -$ devsync package uninstall python-dev-setup -``` - -Uninstall without confirmation: - -``` -$ devsync package uninstall python-dev-setup --yes -``` - -Uninstall from a specific project: - -``` -$ devsync package uninstall python-dev-setup --project ~/my-app --yes -``` - -!!! warning - Uninstalling removes all component files that the package installed (instructions, MCP configs, hooks, commands, resources) and deletes the tracking record. - ---- - -## package create - -Create a shareable configuration package by scanning your project for AI coding assistant components. - -### Usage - -``` -$ devsync package create [options] -``` - -### Options - -| Option | Short | Description | Default | -|--------|-------|-------------|---------| -| `--name` | `-n` | Package name (lowercase, hyphens allowed) | Prompted | -| `--version` | `-v` | Package version (semver) | `1.0.0` | -| `--description` | `-d` | Package description | Prompted | -| `--author` | `-a` | Package author | Git `user.name` | -| `--license` | `-l` | Package license | `MIT` | -| `--output` | `-o` | Output directory | `.` | -| `--project` | `-p` | Project root to scan | Current directory | -| `--interactive/--no-interactive` | | Interactive component selection | `true` | -| `--scrub-secrets/--keep-secrets` | | Template secrets in MCP configs | Scrub | -| `--force` | `-f` | Overwrite existing package directory | `false` | -| `--quiet` | `-q` | Minimal output | `false` | -| `--json` | | Output results as JSON | `false` | - -### Examples - -Create a package interactively: - -``` -$ devsync package create --name my-package -``` - -Create non-interactively with all options: - -``` -$ devsync package create --name dev-setup --description "Dev environment" --no-interactive -``` - -Create to a specific output directory: - -``` -$ devsync package create --name my-package --output ~/packages -``` - -!!! tip - The `create` command scans for instructions, MCP configs, hooks, commands, and resources in your project. In interactive mode, you can select which components to include. Use `--scrub-secrets` (the default) to replace credential values with placeholders. diff --git a/docs/cli/setup.md b/docs/cli/setup.md new file mode 100644 index 0000000..eb3395e --- /dev/null +++ b/docs/cli/setup.md @@ -0,0 +1,69 @@ +# devsync setup + +Configure your LLM provider for AI-powered extraction and installation. This is a one-time setup. + +## Usage + +``` +$ devsync setup +``` + +No options. The command runs an interactive wizard. + +## What It Does + +1. **Provider selection** -- choose Anthropic, OpenAI, or OpenRouter +2. **API key detection** -- checks for environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`) +3. **Model override** -- optionally specify a non-default model +4. **Save configuration** -- writes to `~/.devsync/config.yaml` + +## Example + +``` +$ devsync setup + +? Select LLM provider: + 1. Anthropic (Claude) + 2. OpenAI + 3. OpenRouter +> 1 + +? API key found in ANTHROPIC_API_KEY. Use it? [Y/n]: y + +? Override default model? (claude-sonnet-4-20250514) [y/N]: n + +Configuration saved to ~/.devsync/config.yaml +``` + +## Supported Providers + +| Provider | Env Variable | Default Model | +|----------|-------------|--------------| +| Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | +| OpenAI | `OPENAI_API_KEY` | `gpt-4o` | +| OpenRouter | `OPENROUTER_API_KEY` | `anthropic/claude-sonnet-4-20250514` | + +## Configuration File + +The configuration is stored at `~/.devsync/config.yaml`: + +```yaml +llm: + provider: anthropic + model: claude-sonnet-4-20250514 +``` + +!!! warning "Security" + API keys are **never** written to disk. Only the provider name and model are saved. Keys are read from environment variables at runtime. + +## When Is Setup Required? + +Setup is required before using AI-powered extraction or installation. If you skip setup: + +- Commands with `--no-ai` still work (file-copy mode) +- `devsync tools` works without setup +- `devsync list` and `devsync uninstall` work without setup + +## Re-running Setup + +Run `devsync setup` again to change your provider or model. The new configuration overwrites the previous one. diff --git a/docs/cli/uninstall.md b/docs/cli/uninstall.md index 380a3a5..4414579 100644 --- a/docs/cli/uninstall.md +++ b/docs/cli/uninstall.md @@ -1,29 +1,29 @@ # devsync uninstall -Remove an installed instruction from your AI tools at the project level. +Remove an installed package from your project's AI tools. ## Usage ``` -$ devsync uninstall [options] +$ devsync uninstall [OPTIONS] ``` ## Arguments | Argument | Description | Required | |----------|-------------|----------| -| `name` | Instruction name to uninstall | Yes | +| `name` | Package name to uninstall | Yes | ## Options | Option | Short | Description | |--------|-------|-------------| -| `--tool` | `-t` | Uninstall from a specific AI tool only (`cursor`, `copilot`, `windsurf`, `claude`, etc.) | +| `--tool` | `-t` | Uninstall from a specific AI tool only | | `--force` | `-f` | Skip the confirmation prompt | ## How It Works -The `uninstall` command removes instruction files from your project's AI tool configuration directories and updates the installation tracker. It only affects project-level installations. +The `uninstall` command removes files installed by a package from your project's AI tool configuration directories and updates the installation tracker (`.devsync/packages.json`). It only affects project-level installations. Before removing, DevSync shows what will be uninstalled and asks for confirmation (unless `--force` is used). @@ -32,15 +32,15 @@ Before removing, DevSync shows what will be uninstalled and asks for confirmatio ### Uninstall from all tools ``` -$ devsync uninstall python-best-practices +$ devsync uninstall team-standards ``` -This removes the instruction from every AI tool it was installed to in the current project. +This removes all files installed by the package from every AI tool in the current project. ### Uninstall from a specific tool ``` -$ devsync uninstall python-best-practices --tool cursor +$ devsync uninstall team-standards --tool cursor ``` Only removes from Cursor, leaving other tools untouched. @@ -48,16 +48,16 @@ Only removes from Cursor, leaving other tools untouched. ### Uninstall without confirmation ``` -$ devsync uninstall python-best-practices --force +$ devsync uninstall team-standards --force ``` ### Typical workflow ``` -$ devsync list installed # See what's installed -$ devsync uninstall python-best-practices # Remove it -$ devsync list installed # Verify removal +$ devsync list # See what's installed +$ devsync uninstall team-standards # Remove it +$ devsync list # Verify removal ``` !!! tip - Uninstalling removes the file from your AI tool's directory (e.g., `.cursor/rules/`) but does not remove the instruction from your local library. You can reinstall it later with `devsync install`. + Uninstalling removes files from your AI tool directories (e.g., `.cursor/rules/`, `.claude/rules/`) and updates the tracking in `.devsync/packages.json`. You can reinstall the package later with `devsync install`. diff --git a/docs/cli/update.md b/docs/cli/update.md deleted file mode 100644 index 306341a..0000000 --- a/docs/cli/update.md +++ /dev/null @@ -1,66 +0,0 @@ -# devsync update - -Update downloaded instructions in your library to their latest versions from source. - -## Usage - -``` -$ devsync update [options] -``` - -## Options - -| Option | Short | Description | Required | -|--------|-------|-------------|----------| -| `--namespace` | `-n` | Repository namespace to update | No* | -| `--all` | `-a` | Update all repositories in the library | No* | - -*One of `--namespace` or `--all` is required. - -## How It Works - -The `update` command pulls the latest changes from source repositories and refreshes both the library and any installed instruction files. - -**Branch-based downloads** are updated automatically. If you downloaded from a branch (e.g., `main` or `develop`), `update` pulls the latest commits. - -**Tag and commit-based downloads** are immutable and will be skipped. This is by design -- pinned versions should not change unexpectedly. - -!!! warning - Local sources (directories copied with `devsync download --from ./path`) cannot be updated because they have no remote Git history. These are skipped during updates. - -## Examples - -### Update a specific source - -``` -$ devsync update --namespace github.com_company_instructions -``` - -!!! tip - Not sure what namespace to use? Run `devsync list library` to see all namespaces in your library. - -### Update all sources - -``` -$ devsync update --all -``` - -### Typical workflow - -``` -$ devsync list library # Find the namespace -$ devsync update --namespace my-source # Update it -$ devsync list library --instructions # Verify updated instructions -``` - -## Update Behavior Summary - -| Download Type | Updated? | Reason | -|---------------|----------|--------| -| Branch (e.g., `main`) | Yes | Branches track latest changes | -| No ref specified | Yes | Uses default branch | -| Tag (e.g., `v1.0.0`) | Skipped | Tags are immutable | -| Commit (e.g., `abc123`) | Skipped | Commits are immutable | -| Local directory | Skipped | No Git remote to pull from | - -When updates are pulled, any instructions from that source that are already installed in your AI tools are also refreshed with the new content. diff --git a/docs/getting-started/concepts.md b/docs/getting-started/concepts.md index 6cf0cb8..a5596e2 100644 --- a/docs/getting-started/concepts.md +++ b/docs/getting-started/concepts.md @@ -1,138 +1,109 @@ # Core Concepts -Key ideas behind how DevSync works. +Key ideas behind how DevSync v2 works. -## Library +## Practices -The **library** is your local cache of downloaded instruction repositories, stored at `~/.devsync/library/`. Think of it like a local package cache -- you download once, install many times. +A **practice** is an abstract declaration of a coding standard or convention. Unlike raw instruction files, practices capture the _intent_ behind a rule, making them adaptable across different projects and IDEs. -``` -~/.devsync/library/ -├── company/ # namespace: "company" -│ ├── ai-config-kit.yaml -│ └── instructions/ -│ ├── python-style.md -│ └── security-checklist.md -└── personal/ # namespace: "personal" - ├── ai-config-kit.yaml - └── instructions/ - └── my-preferences.md -``` +A practice declaration includes: -Commands: +| Field | Description | +|-------|-------------| +| `name` | Short identifier (e.g., `type-safety`) | +| `intent` | One-line description of what it enforces | +| `principles` | List of rules and guidelines | +| `enforcement_patterns` | How to enforce (CI checks, linting, etc.) | +| `examples` | Code examples demonstrating the practice | +| `tags` | Categorization tags | -- `devsync download` -- add a repo to your library -- `devsync list library` -- see what's downloaded -- `devsync update` -- pull latest from upstream -- `devsync delete` -- remove from library +When installed, DevSync's AI adapts each practice to the recipient's existing rules, merging rather than blindly overwriting. -## Namespaces +## Packages -Every downloaded repository gets a **namespace** -- a unique identifier used to organize instructions in the library. By default, the namespace is derived from the Git URL (e.g., `github.com_company_instructions`). Use `--as` to set a friendly alias: +A **package** is a shareable bundle containing practices, MCP server configurations, and metadata. Packages are defined by a `devsync-package.yaml` manifest. -```bash -$ devsync download --from github.com/company/standards --as company +``` +team-standards/ +├── devsync-package.yaml # Package manifest +├── practices/ # Practice declaration files +│ ├── type-safety.md +│ └── error-handling.md +├── mcp/ # MCP server configurations +│ └── github.json +└── README.md ``` -Now you can reference instructions as `company/python-style` instead of the full URL-based namespace. - -## Instructions +Packages can also include v1-format components (instructions, hooks, commands, resources) for backward compatibility with `ai-config-kit-package.yaml` manifests. -An **instruction** is a single markdown file containing guidance for an AI coding assistant. Instructions are defined in `ai-config-kit.yaml`: +## Extraction -```yaml -name: Python Standards -version: 1.0.0 +**Extraction** is the process of reading a project's existing AI rules and configurations to produce a package of practice declarations. -instructions: - - name: python-style - description: Python coding style guide - file: instructions/python-style.md - tags: [python, style] +```bash +$ devsync extract --output ./team-standards --name team-standards ``` -When installed, the instruction file is copied to the IDE-specific location (e.g., `.cursor/rules/python-style.mdc` for Cursor). - -## Bundles +The AI reads rule files from `.claude/rules/`, `.cursor/rules/`, MCP configs, and other tool-specific locations. It produces abstract practice declarations that capture the intent of each rule, independent of any specific IDE format. -A **bundle** groups related instructions together for batch installation: +In **file-copy mode** (`--no-ai`), extraction copies source files verbatim without AI processing. -```yaml -bundles: - - name: python-backend - description: All Python backend standards - instructions: [python-style, error-handling, api-design] -``` +## Installation -Install a bundle to get all its instructions at once: +**Installation** takes a package and applies its practices to a target project's AI tools. ```bash -$ devsync install python-backend --bundle +$ devsync install ./team-standards ``` -## Installation Scope +With AI enabled, installation reads the target project's existing rules and intelligently merges incoming practices -- avoiding duplication, resolving conflicts, and adapting content to the recipient's conventions. -DevSync supports two scopes: +In **file-copy mode** (`--no-ai`), installation copies files directly to tool-specific directories without AI adaptation. -| Scope | Location | Active | Best for | -|-------|----------|--------|----------| -| **Project** (default) | `/.claude/rules/` | This project only | Team standards, project-specific rules | -| **Global** | `~/.claude/rules/` | All projects | Personal preferences, company-wide policies | +## Graceful Degradation -```bash -# Project scope (default) -$ devsync template install https://github.com/team/standards --as team +DevSync works with or without an LLM API key: -# Global scope -$ devsync template install https://github.com/personal/prefs --as mine --scope global -``` +| Mode | Extraction | Installation | +|------|-----------|-------------| +| **AI-powered** (default) | Reads rules, produces practice declarations | Adapts practices to existing setup | +| **File-copy** (`--no-ai`) | Copies source files verbatim | Copies files to tool directories | + +No API key? DevSync automatically falls back to file-copy mode. You can also force it with `--no-ai`. -Global and project instructions stack -- your IDE sees both. +## v1 vs v2 Package Format + +DevSync v2 introduces `devsync-package.yaml` with a `practices` section for AI-native content. It also supports the v1 `ai-config-kit-package.yaml` format for backward compatibility. + +| Feature | v1 (`ai-config-kit-package.yaml`) | v2 (`devsync-package.yaml`) | +|---------|----------------------------------|---------------------------| +| Instructions | File-copy only | AI-adapted practices | +| MCP servers | Supported | Supported | +| Hooks, commands, resources | Supported | Supported | +| AI extraction | Not available | Built-in | +| AI installation | Not available | Built-in | + +Use `devsync extract --upgrade ` to convert a v1 package to v2 format. ## Conflict Resolution -When installing an instruction that already exists at the target path, DevSync offers three strategies: +When installing practices that overlap with existing rules, DevSync offers strategies: | Strategy | Behavior | |----------|----------| +| **prompt** | Ask what to do (default) | | **skip** | Leave the existing file, don't install | -| **rename** | Install with a suffix (e.g., `python-style-1.md`) | +| **rename** | Install with a suffix (e.g., `rule-1.md`) | | **overwrite** | Replace the existing file | -By default, DevSync prompts you interactively. Use `--conflict` to choose automatically: +With AI enabled, DevSync can also **merge** practices into existing rules, combining content intelligently. ```bash -$ devsync install python-style --conflict overwrite +$ devsync install ./package --conflict overwrite ``` See [Conflict Resolution](../advanced/conflict-resolution.md) for details. -## Templates - -The **template system** provides richer functionality than basic instructions: - -- **IDE-targeted files** -- different content per IDE -- **Slash commands** -- install commands accessible as `/command-name` -- **Hooks** -- pre-prompt and post-prompt automation -- **Backups** -- automatic backup before updates -- **Validation** -- verify template structure before installation - -Templates are installed from Git repos containing a specific directory structure. See the [CLI template commands](../cli/index.md#template-management) for usage. - -## Packages - -**Packages** are the most comprehensive distribution unit. A package bundles multiple component types: - -| Component | Description | -|-----------|-------------| -| Instructions | AI assistant guidance files | -| MCP Servers | Model Context Protocol server configs | -| Hooks | Pre/post prompt automation scripts | -| Commands | Custom slash commands | -| Resources | Supporting files (configs, data) | - -Packages are defined by an `ai-config-kit-package.yaml` manifest. See the [Packages guide](../packages/index.md). - ## Project Root Detection DevSync automatically detects your project root by looking for markers like `.git/`, `pyproject.toml`, `package.json`, `Cargo.toml`, and others. This lets you run `devsync` from any subdirectory within a project. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index bdc430b..c843e8a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -12,7 +12,7 @@ Verify the installation: ```bash $ devsync version -DevSync version 0.10.0 +DevSync version 2.0.0 ``` !!! tip @@ -28,6 +28,16 @@ DevSync version 0.10.0 - **Git** (for cloning instruction repositories) - **One or more AI coding tools** (see [IDE Integrations](../ide-integrations/index.md)) +## Configure LLM Provider + +DevSync v2 uses an LLM to intelligently adapt practices to each IDE. Configure your provider once after installation: + +```bash +$ devsync setup +``` + +This prompts for your LLM provider (Anthropic, OpenAI, or OpenRouter) and API key, stored in `~/.devsync/config.yaml`. Without a configured provider, DevSync falls back to file-copy mode. + ## Check Detected IDEs After installing, verify which AI tools DevSync detects on your system: @@ -36,7 +46,7 @@ After installing, verify which AI tools DevSync detects on your system: $ devsync tools ``` -This scans for installed tools and shows their configuration paths. DevSync supports 22 AI coding assistants -- see the [full list](../ide-integrations/index.md). +This scans for installed tools and shows their configuration paths. DevSync supports 23+ AI coding assistants -- see the [full list](../ide-integrations/index.md). ## Upgrading diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 4a0a929..ccafaca 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -8,7 +8,30 @@ Get DevSync working in 5 minutes. $ pip install devsync ``` -## 2. Check Your IDEs +## 2. Configure Your LLM Provider + +```bash +$ devsync setup +``` + +DevSync uses an LLM to intelligently extract and adapt coding practices. The setup wizard configures your provider: + +``` +? Select LLM provider: + 1. Anthropic (Claude) + 2. OpenAI + 3. OpenRouter +> 1 + +? API key found in ANTHROPIC_API_KEY. Use it? [Y/n]: y + +Configuration saved to ~/.devsync/config.yaml +``` + +!!! info + No API key? DevSync still works. Use `--no-ai` on any command to fall back to file-copy mode, which copies files verbatim instead of using AI extraction/adaptation. + +## 3. Check Your IDEs ```bash $ devsync tools @@ -23,89 +46,116 @@ Detected AI Tools: Windsurf .windsurf/rules/ (.md) ``` -## 3. Download Instructions +## 4. Extract Practices from a Project -Download a repository of instructions to your local library: +Navigate to a project that has existing AI rules or configs, then extract: ```bash -$ devsync download --from https://github.com/troylar/devsync-starter-templates --as starter +$ cd ~/my-team-project +$ devsync extract --output ./team-standards --name team-standards ``` -This clones the repo to `~/.devsync/library/company/`. The `--as` flag sets a friendly alias. +DevSync reads the project's rules (`.claude/rules/`, `.cursor/rules/`, MCP configs, etc.) and produces a shareable package with abstract practice declarations: -!!! info - You can also download from local directories: +``` +Extracting practices from /home/user/my-team-project... + + Found 5 rule files across 2 AI tools + Found 1 MCP server configuration + +Extracted: + Practices: 4 + MCP servers: 1 + +Package written to: ./team-standards/devsync-package.yaml +``` + +!!! tip + Use `--no-ai` to skip AI extraction and copy files verbatim instead: ```bash - $ devsync download --from ./my-instructions --as local + $ devsync extract --output ./team-standards --name team-standards --no-ai ``` -## 4. Browse and Install +## 5. Install Practices into Another Project -Launch the interactive TUI to browse and select instructions: +Navigate to the target project and install: ```bash -$ devsync install +$ cd ~/new-project +$ devsync install ~/my-team-project/team-standards ``` -The TUI shows all instructions in your library. Select which ones to install and which IDEs to target. +DevSync detects your installed AI tools and adapts the practices: -Or install by name directly: +``` +Installing team-standards to /home/user/new-project... -```bash -$ devsync install python-best-practices --tool cursor --tool claude + Detected tools: Claude Code, Cursor + Adapting 4 practices + 1 MCP server... + + Claude Code: + Created: .claude/rules/type-safety.md + Created: .claude/rules/error-handling.md + Merged: .claude/rules/code-style.md (adapted to existing) + Created: .claude/rules/testing.md + + Cursor: + Created: .cursor/rules/type-safety.mdc + Created: .cursor/rules/error-handling.mdc + Merged: .cursor/rules/code-style.mdc (adapted to existing) + Created: .cursor/rules/testing.mdc + + MCP: Configured 1 server (1 credential prompted) + +Installation complete. ``` -## 5. Verify +## 6. Verify Check what's installed: ```bash -$ devsync list installed +$ devsync list ``` -Your AI tools now have the instructions. Open your IDE and the coding assistant will follow them automatically. +Your AI tools now have the practices. Open your IDE and the coding assistant will follow them automatically. --- -## Alternative: Templates +## Install from Git -Templates provide a richer system with slash commands, hooks, and more: +You can install directly from a Git repository: ```bash -# Create a template repo -$ devsync template init my-standards +$ devsync install https://github.com/company/team-standards +``` -# Install from Git -$ devsync template install https://github.com/company/templates --as company +Or install from a local directory: -# List installed -$ devsync template list +```bash +$ devsync install ./path/to/package ``` -See the [CLI reference](../cli/index.md) for full template commands. - --- -## Alternative: Packages +## File-Copy Mode (No AI) -Packages bundle instructions, MCP servers, hooks, and commands together: +Every command works without an API key using the `--no-ai` flag: ```bash -# Install a complete package -$ devsync package install ./example-package --ide claude +# Extract by copying files verbatim +$ devsync extract --output ./pkg --name my-pkg --no-ai -# List installed packages -$ devsync package list +# Install by copying files without AI adaptation +$ devsync install ./pkg --no-ai ``` -See the [Packages guide](../packages/index.md) for details. - --- ## What's Next? -- [Core Concepts](concepts.md) -- understand the library, namespaces, and scopes -- [CLI Reference](../cli/index.md) -- all commands with examples +- [Core Concepts](concepts.md) -- understand practices, packages, and adaptation +- [CLI Reference](../cli/index.md) -- all 6 commands with examples - [IDE Integrations](../ide-integrations/index.md) -- setup guide for each AI tool - [Tutorials](../tutorials/team-config-repo.md) -- step-by-step walkthroughs diff --git a/docs/ide-integrations/claude-code.md b/docs/ide-integrations/claude-code.md index 705c48d..ccd5b39 100644 --- a/docs/ide-integrations/claude-code.md +++ b/docs/ide-integrations/claude-code.md @@ -37,14 +37,14 @@ my-project/ ### Installing Instructions ```bash -# Install from library interactively -aiconfig install --ide claude +# Install a package from a local path +devsync install ./my-package --tool claude -# Install a specific instruction -aiconfig install my-instruction --ide claude +# Install a package from a Git repository +devsync install https://github.com/acme/standards --tool claude # Install with conflict handling -aiconfig install my-instruction --ide claude --conflict overwrite +devsync install ./my-package --tool claude --conflict overwrite ``` ### Instruction File Format @@ -106,7 +106,7 @@ Claude Code supports MCP (Model Context Protocol) servers for extending its capa ### Installing MCP via Packages ```bash -aiconfig package install ./my-package --ide claude +devsync install ./my-package --tool claude ``` DevSync merges MCP server entries into the existing configuration file without overwriting unrelated entries. @@ -166,7 +166,7 @@ The `CLAUDE.md` file at the project root persists context across Claude Code ses DevSync can install memory file content as part of a package: ```bash -aiconfig package install ./team-setup --ide claude +devsync install ./team-setup --tool claude ``` !!! warning @@ -179,7 +179,7 @@ aiconfig package install ./team-setup --ide claude DevSync detects Claude Code by checking for the `~/.claude/` configuration directory. You can verify detection with: ```bash -aiconfig tools +devsync tools ``` --- @@ -206,7 +206,7 @@ Claude Code supports all DevSync package component types: Given a package with all component types: ```bash -aiconfig package install ./full-stack-setup --ide claude +devsync install ./full-stack-setup --tool claude ``` Result: diff --git a/docs/ide-integrations/codex.md b/docs/ide-integrations/codex.md index 5f187a3..0e9baa8 100644 --- a/docs/ide-integrations/codex.md +++ b/docs/ide-integrations/codex.md @@ -57,14 +57,14 @@ Each instruction is wrapped in a pair of HTML comment markers: ## Installing Instructions ```bash -# Install interactively -aiconfig install --ide codex +# Install a package from a local path +devsync install ./my-package --tool codex -# Install a specific instruction -aiconfig install my-instruction --ide codex +# Install a package from a Git repository +devsync install https://github.com/acme/standards --tool codex # Overwrite an existing section -aiconfig install my-instruction --ide codex --conflict overwrite +devsync install ./my-package --tool codex --conflict overwrite ``` ### What Happens During Installation @@ -79,7 +79,7 @@ aiconfig install my-instruction --ide codex --conflict overwrite ## Uninstalling Instructions ```bash -aiconfig uninstall my-instruction --ide codex +devsync uninstall my-package --tool codex ``` DevSync removes the section between the matching markers and cleans up extra blank lines. Other content in `AGENTS.md` -- including other DevSync sections and manually written content -- is preserved. @@ -92,9 +92,9 @@ Codex CLI, [Amp](other-ides.md#amp), and [OpenCode](other-ides.md#opencode) all ```bash # These all write to the same AGENTS.md -aiconfig install my-instruction --ide codex -aiconfig install my-instruction --ide amp -aiconfig install my-instruction --ide opencode +devsync install ./my-package --tool codex +devsync install ./my-package --tool amp +devsync install ./my-package --tool opencode ``` !!! note @@ -107,7 +107,7 @@ aiconfig install my-instruction --ide opencode DevSync detects Codex CLI by checking if the `codex` binary is available on the system PATH. Verify with: ```bash -aiconfig tools +devsync tools ``` --- @@ -129,10 +129,8 @@ aiconfig tools ## Example: Multiple Instructions ```bash -# Install several instructions -aiconfig install code-style --ide codex -aiconfig install security-rules --ide codex -aiconfig install api-conventions --ide codex +# Install a package containing several practices +devsync install ./team-standards --tool codex ``` Result in `AGENTS.md`: diff --git a/docs/ide-integrations/copilot.md b/docs/ide-integrations/copilot.md index 612d6d3..d3f5fea 100644 --- a/docs/ide-integrations/copilot.md +++ b/docs/ide-integrations/copilot.md @@ -36,11 +36,11 @@ my-project/ ### Installing Instructions ```bash -# Install interactively -aiconfig install --ide copilot +# Install a package from a local path +devsync install ./my-package --tool copilot -# Install a specific instruction -aiconfig install my-instruction --ide copilot +# Install a package from a Git repository +devsync install https://github.com/acme/standards --tool copilot ``` ### Alternative: copilot-instructions.md @@ -108,7 +108,7 @@ GitHub Copilot supports MCP servers through VS Code's built-in MCP integration w ### Installing MCP via Packages ```bash -aiconfig package install ./my-package --ide copilot +devsync install ./my-package --tool copilot ``` DevSync merges MCP entries into `.vscode/mcp.json`. @@ -126,7 +126,7 @@ GitHub Copilot does not support hooks, commands, skills, workflows, or resources DevSync detects GitHub Copilot by checking for the VS Code configuration directory and the Copilot extension. Verify with: ```bash -aiconfig tools +devsync tools ``` --- @@ -148,11 +148,8 @@ aiconfig tools ## Example: Project Setup ```bash -# Download team standards -aiconfig download --from github.com/acme/standards --as acme - -# Install to GitHub Copilot -aiconfig install --ide copilot +# Install team standards from a Git repository +devsync install https://github.com/acme/standards --tool copilot ``` Result: @@ -161,8 +158,8 @@ Result: my-project/ .github/ instructions/ - acme--code-style.instructions.md - acme--testing.instructions.md + code-style.instructions.md + testing.instructions.md .vscode/ mcp.json # If package includes MCP servers ``` diff --git a/docs/ide-integrations/cursor.md b/docs/ide-integrations/cursor.md index 0e920e3..8b21d65 100644 --- a/docs/ide-integrations/cursor.md +++ b/docs/ide-integrations/cursor.md @@ -31,11 +31,11 @@ my-project/ ### Installing Instructions ```bash -# Install interactively -aiconfig install --ide cursor +# Install a package from a local path +devsync install ./my-package --tool cursor -# Install a specific instruction -aiconfig install my-instruction --ide cursor +# Install a package from a Git repository +devsync install https://github.com/acme/standards --tool cursor ``` ### The .mdc File Format @@ -114,7 +114,7 @@ Cursor supports MCP servers with a limit of **40 tools** across all configured s ### Installing MCP via Packages ```bash -aiconfig package install ./my-package --ide cursor +devsync install ./my-package --tool cursor ``` DevSync merges MCP entries into the project-level `.cursor/mcp.json` by default. @@ -136,7 +136,7 @@ Installed 3 of 5 components (2 skipped: hooks, commands not supported by Cursor) DevSync detects Cursor by checking for the Cursor application configuration directory. Verify with: ```bash -aiconfig tools +devsync tools ``` --- @@ -158,11 +158,8 @@ aiconfig tools ## Example: Project Setup ```bash -# Download team standards -aiconfig download --from github.com/acme/standards --as acme - -# Install to Cursor -aiconfig install --ide cursor +# Install team standards from a Git repository +devsync install https://github.com/acme/standards --tool cursor ``` Result: @@ -172,6 +169,6 @@ my-project/ .cursor/ mcp.json # MCP servers (if package includes them) rules/ - acme--code-style.mdc # Team coding standard - acme--testing.mdc # Testing conventions + code-style.mdc # Team coding standard + testing.mdc # Testing conventions ``` diff --git a/docs/ide-integrations/index.md b/docs/ide-integrations/index.md index b5be407..33e6d10 100644 --- a/docs/ide-integrations/index.md +++ b/docs/ide-integrations/index.md @@ -1,6 +1,6 @@ # IDE Integrations -DevSync supports **22 AI coding assistants**, each with its own file format, directory structure, and feature set. This page provides a complete comparison and links to detailed guides for the most popular tools. +DevSync supports **23+ AI coding assistants**, each with its own file format, directory structure, and feature set. This page provides a complete comparison and links to detailed guides for the most popular tools. ## How DevSync Handles Different IDEs @@ -9,7 +9,7 @@ AI coding tools fall into two patterns for storing instructions: - **Multi-file**: Each instruction is a separate file in a directory (e.g., `.cursor/rules/code-style.mdc`). DevSync creates one file per instruction. - **Single-file**: All instructions live in one file at the project root (e.g., `AGENTS.md`). DevSync manages sections within the file using HTML comment markers. -DevSync abstracts this difference away. The same `aiconfig install` command works regardless of the target IDE. +DevSync abstracts this difference away. The same `devsync install` command works regardless of the target IDE. --- diff --git a/docs/ide-integrations/other-ides.md b/docs/ide-integrations/other-ides.md index 265520f..d7bdd3a 100644 --- a/docs/ide-integrations/other-ides.md +++ b/docs/ide-integrations/other-ides.md @@ -20,7 +20,7 @@ These tools store each instruction as a separate file in a dedicated directory. Cline reads `.md` files from the `.clinerules/` directory recursively. Files support optional YAML frontmatter with a `paths:` field for conditional activation based on file paths. ```bash -aiconfig install --ide cline +devsync install ./my-package --tool cline ``` ``` @@ -44,7 +44,7 @@ my-project/ Kiro reads markdown files from `.kiro/steering/`. Files support optional YAML frontmatter for inclusion modes: `always`, `fileMatch`, `manual`, and `auto`. ```bash -aiconfig install --ide kiro +devsync install ./my-package --tool kiro ``` ``` @@ -71,8 +71,7 @@ my-project/ Roo Code supports instructions, MCP servers, and slash commands. It also supports mode-specific rules in `.roo/rules-{mode-slug}/` directories (e.g., `.roo/rules-code/`, `.roo/rules-architect/`). ```bash -aiconfig install --ide roo -aiconfig package install ./my-package --ide roo +devsync install ./my-package --tool roo ``` ``` @@ -99,7 +98,7 @@ my-project/ | **Detection** | Amazon Q extension or application config | ```bash -aiconfig install --ide amazonq +devsync install ./my-package --tool amazonq ``` **Supported components**: Instructions, MCP Servers, Resources @@ -116,7 +115,7 @@ aiconfig install --ide amazonq | **Detection** | JetBrains IDE configuration directory | ```bash -aiconfig install --ide jetbrains +devsync install ./my-package --tool jetbrains ``` **Supported components**: Instructions, MCP Servers, Resources @@ -133,7 +132,7 @@ aiconfig install --ide jetbrains | **Detection** | Continue extension in VS Code globalStorage | ```bash -aiconfig install --ide continue +devsync install ./my-package --tool continue ``` **Supported components**: Instructions, MCP Servers, Resources @@ -150,7 +149,7 @@ aiconfig install --ide continue | **Detection** | Trae application configuration directory | ```bash -aiconfig install --ide trae +devsync install ./my-package --tool trae ``` **Supported components**: Instructions, MCP Servers, Resources @@ -167,7 +166,7 @@ aiconfig install --ide trae | **Detection** | Augment extension or application config | ```bash -aiconfig install --ide augment +devsync install ./my-package --tool augment ``` **Supported components**: Instructions, MCP Servers, Resources @@ -184,7 +183,7 @@ aiconfig install --ide augment | **Detection** | Tabnine application configuration directory | ```bash -aiconfig install --ide tabnine +devsync install ./my-package --tool tabnine ``` **Supported components**: Instructions, MCP Servers, Resources @@ -201,7 +200,7 @@ aiconfig install --ide tabnine | **Detection** | OpenHands configuration directory | ```bash -aiconfig install --ide openhands +devsync install ./my-package --tool openhands ``` **Supported components**: Instructions, MCP Servers, Resources @@ -220,7 +219,7 @@ aiconfig install --ide openhands Antigravity IDE (Google's VS Code fork) reads instructions from `.agent/rules/` and MCP configuration from `.mcp.json` at the project root. ```bash -aiconfig install --ide antigravity +devsync install ./my-package --tool antigravity ``` **Supported components**: Instructions, MCP Servers, Resources @@ -262,7 +261,7 @@ For a detailed explanation of how section markers work, see the [Codex CLI](code JetBrains Junie reads a single `.junie/guidelines.md` file. DevSync inserts sections with markers. ```bash -aiconfig install --ide junie +devsync install ./my-package --tool junie ``` **Supported components**: Instructions, Resources @@ -280,7 +279,7 @@ aiconfig install --ide junie Zed reads a single `.rules` file at the project root. MCP servers are configured through `.zed/settings.json`. ```bash -aiconfig install --ide zed +devsync install ./my-package --tool zed ``` **Supported components**: Instructions, MCP Servers, Resources @@ -298,7 +297,7 @@ aiconfig install --ide zed Aider reads a single `CONVENTIONS.md` file at the project root. ```bash -aiconfig install --ide aider +devsync install ./my-package --tool aider ``` **Supported components**: Instructions, Resources @@ -316,7 +315,7 @@ aiconfig install --ide aider Amp uses the same `AGENTS.md` file as Codex CLI and OpenCode. See the [Codex CLI](codex.md#shared-file-codex-cli-amp-and-opencode) page for details on how shared files are handled. ```bash -aiconfig install --ide amp +devsync install ./my-package --tool amp ``` **Supported components**: Instructions, Resources @@ -334,7 +333,7 @@ aiconfig install --ide amp OpenCode uses the same `AGENTS.md` file as Codex CLI and Amp. See the [Codex CLI](codex.md#shared-file-codex-cli-amp-and-opencode) page for details on how shared files are handled. ```bash -aiconfig install --ide opencode +devsync install ./my-package --tool opencode ``` **Supported components**: Instructions, Resources @@ -352,7 +351,7 @@ aiconfig install --ide opencode Gemini CLI and Gemini Code Assist read a single `GEMINI.md` file at the project root. MCP servers are configured globally via `~/.gemini/settings.json`. ```bash -aiconfig install --ide gemini +devsync install ./my-package --tool gemini ``` **Supported components**: Instructions, Resources @@ -391,6 +390,6 @@ The MCP config path is platform-specific: | Windows | `%APPDATA%\Claude\claude_desktop_config.json` | ```bash -# Sync MCP servers to Claude Desktop -aiconfig mcp sync --tool claude-desktop +# Install a package with MCP server configuration to Claude Desktop +devsync install ./my-package --tool claude-desktop ``` diff --git a/docs/ide-integrations/windsurf.md b/docs/ide-integrations/windsurf.md index e89d87f..46e8e18 100644 --- a/docs/ide-integrations/windsurf.md +++ b/docs/ide-integrations/windsurf.md @@ -32,11 +32,11 @@ my-project/ ### Installing Instructions ```bash -# Install interactively -aiconfig install --ide windsurf +# Install a package from a local path +devsync install ./my-package --tool windsurf -# Install a specific instruction -aiconfig install my-instruction --ide windsurf +# Install a package from a Git repository +devsync install https://github.com/acme/standards --tool windsurf ``` ### Activation Modes @@ -81,7 +81,7 @@ Windsurf uses a **global-only** MCP configuration with a limit of **100 tools**. ### Installing MCP via Packages ```bash -aiconfig package install ./my-package --ide windsurf +devsync install ./my-package --tool windsurf ``` DevSync merges MCP entries into the global configuration file. @@ -115,7 +115,7 @@ Windsurf does not support hooks, commands, or skills. When installing a package DevSync detects Windsurf by checking for the Windsurf application configuration directory. Verify with: ```bash -aiconfig tools +devsync tools ``` --- @@ -137,11 +137,8 @@ aiconfig tools ## Example: Project Setup ```bash -# Download team standards -aiconfig download --from github.com/acme/standards --as acme - -# Install to Windsurf -aiconfig install --ide windsurf +# Install team standards from a Git repository +devsync install https://github.com/acme/standards --tool windsurf ``` Result: @@ -150,8 +147,8 @@ Result: my-project/ .windsurf/ rules/ - acme--code-style.md - acme--testing.md + code-style.md + testing.md workflows/ - acme--deploy.md # If package includes workflows + deploy.md # If package includes workflows ``` diff --git a/docs/index.md b/docs/index.md index c4723c7..9eae871 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,8 +1,8 @@ # DevSync -**Distribute and sync AI coding assistant configurations across your team.** +**AI-powered dev config distribution across AI coding assistants.** -DevSync is a CLI tool that manages instructions, MCP servers, and configuration packages for 22+ AI coding assistants. Download shared configs from Git repos, install them to any IDE, and keep your team aligned with a single command. +DevSync extracts coding practices from any project and installs them into 23+ AI coding assistants with intelligent adaptation. Two commands replace hours of manual configuration copying. --- @@ -10,9 +10,9 @@ DevSync is a CLI tool that manages instructions, MCP servers, and configuration
-### Instructions +### AI-Powered Extraction -Share coding standards, style guides, and AI prompts across your team. Works with all 22 supported IDEs. +Extract coding standards, style guides, and MCP configs from any project into shareable practice declarations. [Get started](getting-started/quickstart.md){ .md-button } @@ -20,11 +20,11 @@ Share coding standards, style guides, and AI prompts across your team. Works wit
-### MCP Servers +### Intelligent Installation -Distribute Model Context Protocol server configurations with secure credential management. +AI adapts incoming practices to your existing setup -- merging, not overwriting. -[MCP docs](mcp-server/index.md){ .md-button } +[Install guide](cli/install.md){ .md-button }
@@ -32,7 +32,7 @@ Distribute Model Context Protocol server configurations with secure credential m ### Configuration Packages -Bundle instructions, MCP servers, hooks, commands, and resources into installable packages. +Bundle practices, MCP servers, hooks, commands, and resources into installable packages. [Package guide](packages/index.md){ .md-button } @@ -40,9 +40,9 @@ Bundle instructions, MCP servers, hooks, commands, and resources into installabl
-### 23 IDE Integrations +### 23+ IDE Integrations -Claude Code, Cursor, Windsurf, GitHub Copilot, Codex CLI, Cline, Kiro, Roo Code, Anteroom, and 14 more. +Claude Code, Cursor, Windsurf, GitHub Copilot, Codex CLI, Cline, Kiro, Roo Code, Anteroom, and more. [See all IDEs](ide-integrations/index.md){ .md-button } @@ -58,14 +58,19 @@ Claude Code, Cursor, Windsurf, GitHub Copilot, Codex CLI, Cline, Kiro, Roo Code, # Install DevSync $ pip install devsync +# Configure your LLM provider (one-time) +$ devsync setup + # Check which AI tools are detected $ devsync tools -# Download instructions from a Git repo -$ devsync download --from github.com/company/standards --as company +# Extract practices from a project with existing rules +$ cd ~/my-team-project +$ devsync extract --output ./team-standards --name team-standards -# Install to your IDE (interactive TUI) -$ devsync install +# Install those practices into another project +$ cd ~/new-project +$ devsync install ~/my-team-project/team-standards ``` See the full [quickstart guide](getting-started/quickstart.md) for a 5-minute walkthrough. @@ -85,8 +90,8 @@ See the full [quickstart guide](getting-started/quickstart.md) for a 5-minute wa - **Portable** -- same setup across all your machines - **Composable** -- layer company, team, and personal configs -- **Discoverable** -- install from any Git repository -- **Safe** -- automatic backups, conflict resolution, checksums +- **Works without AI** -- graceful degradation to file-copy mode when no API key +- **Safe** -- conflict resolution, checksums, installation tracking --- @@ -119,19 +124,19 @@ See the full [quickstart guide](getting-started/quickstart.md) for a 5-minute wa ## How It Works ``` -┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐ -│ Git Repository │────>│ Local │────>│ Project IDE │ -│ (team configs) │ │ Library │ │ Config Files │ -│ │ │ ~/.devsync/ │ │ .cursor/rules/ │ -└─────────────────┘ └──────────────┘ │ .claude/rules/ │ - devsync download devsync install │ .windsurf/... │ - └─────────────────┘ +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ Source Project │────>│ Package with │────>│ Target Project │ +│ (team configs) │ │ Practices │ │ IDE Config │ +│ │ │ │ │ .cursor/rules/ │ +└─────────────────┘ └──────────────────┘ │ .claude/rules/ │ + devsync extract devsync install │ .windsurf/... │ + └─────────────────┘ ``` -1. **Download** instruction repos to your local library (`~/.devsync/library/`) -2. **Browse** instructions with the interactive TUI or CLI -3. **Install** to any AI tool at project level -4. **Update** with a single command when upstream changes +1. **Extract** practices from a project's existing rules, MCP configs, and commands +2. **Share** the resulting package via Git or local directory +3. **Install** into any project -- AI adapts practices to the recipient's existing setup +4. **Track** installations in `.devsync/packages.json` for management --- @@ -140,10 +145,9 @@ See the full [quickstart guide](getting-started/quickstart.md) for a 5-minute wa | Section | What you'll find | |---------|-----------------| | [Getting Started](getting-started/installation.md) | Installation, quickstart, core concepts | -| [CLI Reference](cli/index.md) | All commands with examples | +| [CLI Reference](cli/index.md) | All 6 commands with examples | | [IDE Integrations](ide-integrations/index.md) | Setup guides for each AI tool | | [Packages](packages/index.md) | Creating and installing config packages | -| [MCP Server](mcp-server/index.md) | devsync-mcp for AI-powered config merging | | [Tutorials](tutorials/team-config-repo.md) | Step-by-step walkthroughs | | [Advanced](advanced/config-types.md) | Config types, conflict resolution, contributing | | [Reference](reference/cli-reference.md) | Full CLI reference, YAML schemas | diff --git a/docs/mcp-server/ai-merge-flow.md b/docs/mcp-server/ai-merge-flow.md deleted file mode 100644 index cc46541..0000000 --- a/docs/mcp-server/ai-merge-flow.md +++ /dev/null @@ -1,133 +0,0 @@ -# AI Merge Flow - -AI merge flow uses an AI assistant -- connected via MCP -- to intelligently merge configuration changes instead of relying on simple overwrite or skip strategies. The assistant reads your current config, compares it with incoming changes, understands the intent behind both, and proposes a merge that preserves your customizations while incorporating updates. - -!!! note "Forward-looking feature" - AI merge flow depends on the `devsync-mcp` package. The workflow described here represents the planned design. Implementation details will be refined as `devsync-mcp` matures. - ---- - -## Why AI-Assisted Merging? - -Standard conflict resolution strategies have limitations: - -| Strategy | Problem | -|----------|---------| -| **Skip** | You miss important updates from the upstream template | -| **Overwrite** | You lose local customizations | -| **Rename** | You accumulate duplicate files and must manually reconcile | - -AI merge flow adds a fourth option: **understand both sides and produce an intelligent merge**. - -Consider a team coding standard that you have customized for your project. When the team publishes an update, a simple overwrite destroys your customizations. A skip means you miss the update. An AI assistant can read both versions, identify what changed upstream, identify your local modifications, and produce a merged result that incorporates both. - ---- - -## Workflow - -### 1. Detect Changes - -The AI assistant uses `devsync_compare_configs` to identify differences between your installed configuration and an incoming update. - -```text -You: Update my backend MCP servers to the latest version. - -AI: I'll check what changed. [calls devsync_compare_configs] - -Found 3 differences in namespace 'backend': -- github server: args updated (mcp-server-github v1.2 -> v1.4) -- postgres server: new env var DATABASE_READONLY added -- redis server: newly added (not in your current config) -``` - -### 2. Analyze Conflicts - -The assistant identifies which changes conflict with your local modifications and which can be applied cleanly. - -```text -AI: Analyzing against your current configuration... - -No conflicts: -- redis server: new addition, no local equivalent -- postgres DATABASE_READONLY: new variable, does not affect existing ones - -Conflict detected: -- github server: you added custom args ["--org", "acme-corp"] - Upstream changed args from [mcp-server-github] to [mcp-server-github, "--include-forks"] -``` - -### 3. Propose Merge Strategy - -The assistant suggests how to handle each change based on context. - -```text -AI: Here is my proposed merge: - -1. github server: Merge args to include both your custom flags and the upstream update - Result: [mcp-server-github, "--include-forks", "--org", "acme-corp"] - -2. postgres server: Add DATABASE_READONLY with null (requires your credential) - No existing config affected. - -3. redis server: Add as new server. - You will need to configure REDIS_URL. - -Apply this merge? [yes/no/edit] -``` - -### 4. Apply Changes - -After confirmation, the assistant uses `devsync_merge_config` to apply the changes and creates a backup of the previous state. - -```text -You: Yes, apply it. - -AI: [calls devsync_merge_config] - -Merge complete: -- Backup created at .devsync/backups/2026-02-15T103045/ -- 3 servers updated -- 1 new credential required: REDIS_URL - -Run 'devsync mcp configure backend' to set REDIS_URL, -then 'devsync mcp sync --tool all' to push changes to your AI tools. -``` - ---- - -## Merge Strategies - -The AI assistant can apply different strategies depending on the type of change: - -### Additive Merge - -New items (servers, environment variables, instructions) are added without affecting existing configuration. This is the default for non-conflicting changes. - -### Selective Override - -The upstream change replaces a specific field while preserving the rest. For example, updating a server's command version without changing custom arguments. - -### Manual Resolution - -For changes that cannot be automatically merged, the assistant presents both versions and asks the developer to choose or write a custom resolution. - ---- - -## Safeguards - -AI merge flow includes protections against destructive changes: - -- **Automatic backups** are created before any merge operation. See [Backups & Recovery](backups-and-recovery.md). -- **Dry run mode** lets you preview the merge result without applying it. -- **Credential preservation** -- merge operations never modify `.devsync/.env` files. New credentials are flagged for manual configuration. -- **Rollback** -- if a merge produces an undesirable result, restore from the backup. - ---- - -## Prerequisites - -To use AI merge flow: - -1. Install the `devsync-mcp` server package: `pip install devsync-mcp` -2. Register `devsync-mcp` as an MCP server in your AI tool (see [Tools Reference](tools-reference.md)) -3. Have an AI assistant that supports MCP tool use (Claude Code, Cursor, etc.) diff --git a/docs/mcp-server/backups-and-recovery.md b/docs/mcp-server/backups-and-recovery.md deleted file mode 100644 index 15b9328..0000000 --- a/docs/mcp-server/backups-and-recovery.md +++ /dev/null @@ -1,197 +0,0 @@ -# Backups & Recovery - -DevSync automatically backs up configuration files before modifying them. This applies to MCP config syncs, template installations, and package operations. If something goes wrong, you can list, inspect, and restore from any previous backup. - ---- - -## How Backups Work - -When DevSync writes to an AI tool's config file (e.g., `.cursor/mcp.json` or `claude_desktop_config.json`), it first copies the existing file to the backup directory. Each backup is timestamped and grouped by operation. - -**Backup storage location:** - -``` -.devsync/backups/ - 2026-02-15T103045/ - cursor_mcp.json - claude_settings.local.json - 2026-02-14T091530/ - cursor_mcp.json -``` - -The timestamp format is `YYYY-MM-DDTHHMMSS` in local time. - ---- - -## Automatic Backups - -Backups are created automatically during these operations: - -| Operation | What Gets Backed Up | -|-----------|-------------------| -| `devsync mcp sync` | AI tool MCP config files before overwrite | -| `devsync template install` | Existing instruction files that would be overwritten | -| `devsync package install` | All files that would be modified or replaced | -| AI merge flow | Full config state before merge is applied | - -To skip backup creation for a specific operation: - -```bash -devsync mcp sync --tool all --no-backup -``` - -!!! warning - Skipping backups means you cannot roll back if the sync produces an undesirable result. Use `--no-backup` only when you are confident in the operation or have your own backup strategy. - ---- - -## Listing Backups - -```bash -devsync template backup list -``` - -Output: - -``` -Backups for current project: - -Timestamp Files Size Operation -2026-02-15 10:30:45 3 12 KB mcp sync -2026-02-14 09:15:30 1 4 KB mcp sync -2026-02-12 14:22:10 5 28 KB package install -2026-02-10 08:45:00 2 8 KB template install - -Total: 4 backups, 52 KB -``` - -### Filtering - -```bash -# List only MCP-related backups -devsync template backup list --type mcp - -# List backups from a specific date range -devsync template backup list --since 2026-02-14 - -# JSON output -devsync template backup list --json -``` - ---- - -## Restoring from Backup - -Restore all files from a specific backup: - -```bash -devsync template backup restore 2026-02-15T103045 -``` - -Output: - -``` -Restoring backup from 2026-02-15 10:30:45 - -Restored: - .cursor/mcp.json - .claude/settings.local.json - -2 file(s) restored. -Current configs backed up to: .devsync/backups/2026-02-15T110000/ -``` - -!!! info - Restoring a backup creates a new backup of the current state first. This means you can always undo a restore operation. - -### Selective Restore - -Restore a specific file from a backup: - -```bash -devsync template backup restore 2026-02-15T103045 --file cursor_mcp.json -``` - -### Dry Run - -Preview what would be restored without making changes: - -```bash -devsync template backup restore 2026-02-15T103045 --dry-run -``` - ---- - -## Cleanup - -Old backups accumulate over time. The cleanup command removes backups older than a specified retention period. - -```bash -# Remove backups older than 30 days (default) -devsync template backup cleanup - -# Remove backups older than 7 days -devsync template backup cleanup --days 7 - -# Preview what would be removed -devsync template backup cleanup --dry-run -``` - -Output: - -``` -Cleaning up backups older than 30 days - -Removed: - 2026-01-10T083000/ (3 files, 15 KB) - 2026-01-05T142200/ (1 file, 4 KB) - -Freed 19 KB across 2 backup(s). -Remaining: 4 backup(s), 52 KB -``` - ---- - -## Backup Directory Structure - -``` -.devsync/ - backups/ - 2026-02-15T103045/ # One directory per operation - cursor_mcp.json # Backed-up config files - claude_settings.local.json - manifest.json # Metadata about the backup - 2026-02-14T091530/ - cursor_mcp.json - manifest.json -``` - -The `manifest.json` in each backup directory records metadata: - -```json title="manifest.json" -{ - "timestamp": "2026-02-15T10:30:45", - "operation": "mcp_sync", - "tool": "all", - "files": [ - { - "name": "cursor_mcp.json", - "original_path": ".cursor/mcp.json", - "size": 4096, - "checksum": "sha256:abc123..." - }, - { - "name": "claude_settings.local.json", - "original_path": ".claude/settings.local.json", - "size": 8192, - "checksum": "sha256:def456..." - } - ] -} -``` - ---- - -## Gitignore - -The `.devsync/backups/` directory is automatically added to `.gitignore`. Backups are local to each developer's machine and should not be committed. diff --git a/docs/mcp-server/ide-setup.md b/docs/mcp-server/ide-setup.md deleted file mode 100644 index 7e42b5b..0000000 --- a/docs/mcp-server/ide-setup.md +++ /dev/null @@ -1,407 +0,0 @@ -# IDE Setup for MCP - -This page documents the MCP configuration file location and JSON format for each AI tool that DevSync supports. Use this as a reference when debugging sync issues or when you need to manually inspect tool configs. - -After running `devsync mcp sync --tool `, DevSync writes to these files automatically. The formats shown here are what DevSync produces. - ---- - -## IDE Configuration Reference - -=== "Claude Code" - - **Config file:** `.claude/settings.local.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - }, - "postgres": { - "command": "python", - "args": ["-m", "mcp_server_postgres"], - "env": { - "DATABASE_URL": "postgresql://localhost/mydb" - } - } - } - } - ``` - - **Notes:** - - - Project-level file, lives in the repository root - - No known tool limit - - Supports all component types (instructions, MCP, hooks, commands, resources) - -=== "Cursor" - - **Config file:** `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - **Tool limit: 40 MCP servers** - - Project-level config takes precedence over global - - DevSync writes to the project-level file by default - -=== "Windsurf" - - **Config file:** `~/.codeium/windsurf/mcp_config.json` (global) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - **Tool limit: 100 MCP servers** - - Global config only -- shared across all projects - - Located in the Codeium application data directory - -=== "GitHub Copilot" - - **Config file:** `.vscode/mcp.json` (project-level) - - ```json - { - "servers": { - "github": { - "type": "stdio", - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - **Tool limit: 128 MCP servers** - - Uses `servers` key (not `mcpServers`) - - Requires `type` field (typically `"stdio"`) - - Shared with VS Code workspace settings directory - -=== "Claude Desktop" - - **Config file:** `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) - - Other platforms: - - - **Linux:** `~/.config/Claude/claude_desktop_config.json` - - **Windows:** `%APPDATA%\Claude\claude_desktop_config.json` - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - Global config, applies to all conversations - - Requires restarting Claude Desktop after config changes - -=== "Amazon Q" - - **Config file:** `.amazonq/mcp.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - -=== "Augment" - - **Config file:** `.augment/mcp.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - -=== "Continue.dev" - - **Config file:** `.continue/config.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - MCP servers are defined alongside other Continue.dev configuration - - DevSync merges into the existing `config.json` without overwriting other settings - -=== "Gemini CLI" - - **Config file:** `~/.gemini/settings.json` (global) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - Global config, applies to all Gemini CLI sessions - -=== "JetBrains AI" - - **Config file:** `.aiassistant/mcp.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - Works with all JetBrains IDEs (IntelliJ, PyCharm, WebStorm, etc.) - -=== "OpenHands" - - **Config file:** `.openhands/mcp.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - -=== "Tabnine" - - **Config file:** `.tabnine/mcp.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - -=== "Trae" - - **Config file:** `.mcp.json` (project root) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - Antigravity also uses `.mcp.json` at the project root - - If both tools are in use, they share the same config file - -=== "Antigravity" - - **Config file:** `.mcp.json` (project root) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - Shares `.mcp.json` with Trae - -=== "Zed" - - **Config file:** `.zed/settings.json` (project-level) - - ```json - { - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxx" - } - } - } - } - ``` - - **Notes:** - - - MCP config is nested within Zed's settings file - - DevSync merges into existing settings without overwriting other Zed configuration - ---- - -## Quick Reference Table - -| IDE | Config File | Scope | Tool Limit | -|-----|------------|-------|:----------:| -| Amazon Q | `.amazonq/mcp.json` | Project | -- | -| Antigravity | `.mcp.json` | Project | -- | -| Augment | `.augment/mcp.json` | Project | -- | -| Claude Code | `.claude/settings.local.json` | Project | -- | -| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | Global | -- | -| Continue.dev | `.continue/config.json` | Project | -- | -| Cursor | `.cursor/mcp.json` | Project/Global | 40 | -| Gemini CLI | `~/.gemini/settings.json` | Global | -- | -| GitHub Copilot | `.vscode/mcp.json` | Project | 128 | -| JetBrains AI | `.aiassistant/mcp.json` | Project | -- | -| OpenHands | `.openhands/mcp.json` | Project | -- | -| Tabnine | `.tabnine/mcp.json` | Project | -- | -| Trae | `.mcp.json` | Project | -- | -| Windsurf | `~/.codeium/windsurf/mcp_config.json` | Global | 100 | -| Zed | `.zed/settings.json` | Project | -- | - ---- - -## Troubleshooting - -### Config file not created - -If `devsync mcp sync` reports success but the config file does not exist, DevSync may not have detected the tool. Run: - -```bash -devsync tools -``` - -This lists which AI tools DevSync detects on your system. If your tool is missing, verify that it is installed and that its standard directories exist. - -### Changes not taking effect - -Some tools require a restart after MCP config changes: - -- **Claude Desktop**: Restart the application -- **Cursor**: Reload the window (Cmd+Shift+P > "Reload Window") -- **VS Code / Copilot**: Reload the window -- **JetBrains**: Restart the IDE - -### Tool limit exceeded - -If you have more MCP servers than a tool supports (e.g., more than 40 for Cursor), DevSync will warn you during sync. Consider using MCP sets to install only the servers relevant to each project: - -```bash -# Install only the backend-dev set -devsync mcp sync --tool cursor --set backend-dev -``` - -### Shared config files - -Trae and Antigravity both use `.mcp.json` at the project root. Syncing to either tool writes to the same file. DevSync handles this by merging configurations rather than overwriting. diff --git a/docs/mcp-server/index.md b/docs/mcp-server/index.md deleted file mode 100644 index dba21f7..0000000 --- a/docs/mcp-server/index.md +++ /dev/null @@ -1,89 +0,0 @@ -# MCP Server Management - -DevSync provides two distinct MCP capabilities: - -1. **MCP Configuration Management** -- The DevSync CLI distributes and syncs MCP server configurations across 15+ AI tools for your entire team. -2. **devsync-mcp** -- A separate MCP server package (`pip install devsync-mcp`) that gives AI assistants direct access to DevSync operations for intelligent config merging. - -This section covers both. - ---- - -## What Is MCP? - -Model Context Protocol (MCP) is an open standard that lets AI coding assistants connect to external tools and data sources. An MCP server exposes capabilities -- database queries, API calls, file operations -- that the AI can invoke during a conversation. - -The problem: every AI tool stores MCP configurations differently. Claude Code uses `.claude/settings.local.json`. Cursor uses `.cursor/mcp.json`. Windsurf uses `~/.codeium/windsurf/mcp_config.json`. When your team uses multiple tools, keeping MCP configs in sync becomes a maintenance burden. - -## Why Managed MCP Configs Matter - -Without centralized management: - -- Team members manually edit tool-specific JSON files -- Credentials end up committed to Git -- Configuration drift accumulates across machines -- Onboarding requires documenting 15 different config file locations -- Updating a server version means touching every developer's setup - -With DevSync: - -```bash -# Share MCP configs via Git (no credentials in repo) -devsync mcp install https://github.com/acme/mcp-servers --as backend - -# Each developer configures their own credentials locally -devsync mcp configure backend - -# Sync to all detected AI tools at once -devsync mcp sync --tool all -``` - -Credentials stay in `.devsync/.env` (gitignored). Configurations propagate to every supported tool with a single command. - -## Supported IDEs - -DevSync can sync MCP server configurations to these AI tools: - -| IDE | Config Location | Tool Limit | -|-----|----------------|:----------:| -| Amazon Q | `.amazonq/mcp.json` | -- | -| Antigravity | `.mcp.json` | -- | -| Augment | `.augment/mcp.json` | -- | -| Claude Code | `.claude/settings.local.json` | -- | -| Claude Desktop | `~/Library/Application Support/Claude/claude_desktop_config.json` | -- | -| Continue.dev | `.continue/config.json` | -- | -| Cursor | `.cursor/mcp.json` | 40 | -| Gemini CLI | `~/.gemini/settings.json` | -- | -| GitHub Copilot | `.vscode/mcp.json` | 128 | -| JetBrains AI | `.aiassistant/mcp.json` | -- | -| OpenHands | `.openhands/mcp.json` | -- | -| Tabnine | `.tabnine/mcp.json` | -- | -| Trae | `.mcp.json` | -- | -| Windsurf | `~/.codeium/windsurf/mcp_config.json` | 100 | -| Zed | `.zed/settings.json` | -- | - -See [IDE Setup](ide-setup.md) for per-tool configuration details. - -## The devsync-mcp Package - -`devsync-mcp` is a separate package that exposes DevSync operations as MCP tools. This allows AI assistants to directly read, compare, and merge configurations during a conversation. - -```bash -pip install devsync-mcp -``` - -This is useful for AI-assisted workflows where the assistant needs to understand your current config state, detect conflicts, and propose intelligent merges. See [Tools Reference](tools-reference.md) and [AI Merge Flow](ai-merge-flow.md) for details. - -!!! note "Separate package" - `devsync-mcp` is installed independently from the main `devsync` CLI. You need the CLI for config management commands. You need `devsync-mcp` only if you want AI assistants to interact with DevSync programmatically. - -## Documentation Map - -| Page | What It Covers | -|------|---------------| -| [Installation & Configuration](installation.md) | Installing MCP repos, configuring credentials, syncing to tools | -| [IDE Setup](ide-setup.md) | Per-IDE config file locations and JSON formats | -| [Tools Reference](tools-reference.md) | The devsync-mcp package's MCP tool APIs | -| [Team Profiles](team-profiles.md) | Defining team-wide configurations with `devsync-profile.yaml` | -| [AI Merge Flow](ai-merge-flow.md) | Using AI assistants to intelligently merge config changes | -| [Backups & Recovery](backups-and-recovery.md) | Automatic backups, restore, and cleanup | diff --git a/docs/mcp-server/installation.md b/docs/mcp-server/installation.md deleted file mode 100644 index 3389b5f..0000000 --- a/docs/mcp-server/installation.md +++ /dev/null @@ -1,303 +0,0 @@ -# Installation & Configuration - -This page covers the full workflow for managing MCP server configurations with the DevSync CLI: creating a config repository, installing it, configuring credentials, and syncing to your AI tools. - ---- - -## Workflow Overview - -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Create MCP │────>│ Install to │────>│ Configure │────>│ Sync to AI │ -│ config repo │ │ local library │ │ credentials │ │ tools │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ - templatekit.yaml devsync mcp install devsync mcp configure devsync mcp sync -``` - -## Step 1: Create an MCP Config Repository - -An MCP config repository is a Git repo containing a `templatekit.yaml` file that defines your MCP servers. - -### Minimal Example - -```yaml title="templatekit.yaml" -name: Backend Servers -version: 1.0.0 -description: MCP servers for backend development - -mcp_servers: - - name: github - command: uvx - args: [mcp-server-github] - env: - GITHUB_PERSONAL_ACCESS_TOKEN: null # (1)! -``` - -1. `null` means this credential is required -- the user must configure it before syncing. - -### Full Example - -```yaml title="templatekit.yaml" -name: Backend Development Servers -version: 2.1.0 -description: MCP servers for the backend team -author: Backend Team - -mcp_servers: - - name: github - command: uvx - args: [mcp-server-github] - env: - GITHUB_PERSONAL_ACCESS_TOKEN: null - tags: [github, scm] - - - name: postgres - command: python - args: [-m, mcp_server_postgres] - env: - DATABASE_URL: null - DATABASE_POOL_SIZE: "10" # (1)! - tags: [database] - - - name: filesystem - command: npx - args: [-y, "@modelcontextprotocol/server-filesystem", "/workspace"] - env: {} # (2)! - tags: [filesystem] - - - name: slack - command: python - args: [-m, mcp_server_slack] - env: - SLACK_BOT_TOKEN: null - SLACK_SIGNING_SECRET: null - tags: [communication] - -mcp_sets: - - name: backend-dev - description: Core servers for backend work - servers: [github, postgres, filesystem] - - - name: full-stack - description: Everything including communication - servers: [github, postgres, filesystem, slack] -``` - -1. A string value provides a default. The user can override it during configuration. -2. Empty `env` means no credentials are needed. - -### Field Reference - -**mcp_servers** - -| Field | Type | Required | Description | -|-------|------|:--------:|-------------| -| `name` | `string` | Yes | Unique server identifier | -| `command` | `string` | Yes | Executable to launch the server (`uvx`, `npx`, `python`) | -| `args` | `list[string]` | Yes | Command-line arguments | -| `env` | `dict` | No | Environment variables. `null` = required credential, string = default value | -| `tags` | `list[string]` | No | Organizational tags | - -**mcp_sets** - -| Field | Type | Required | Description | -|-------|------|:--------:|-------------| -| `name` | `string` | Yes | Set identifier | -| `description` | `string` | No | Human-readable description | -| `servers` | `list[string]` | Yes | Server names to include | -| `tags` | `list[string]` | No | Organizational tags | - -## Step 2: Install to Local Library - -```bash -# Install from a Git repository -devsync mcp install https://github.com/acme/mcp-servers --as backend - -# Install from a specific branch or tag -devsync mcp install https://github.com/acme/mcp-servers --as backend --ref v2.0.0 - -# Install from a local directory -devsync mcp install ./my-mcp-servers --as backend - -# Force reinstall (overwrite existing) -devsync mcp install https://github.com/acme/mcp-servers --as backend --force -``` - -The `--as` flag sets the namespace. Servers are referenced as `.` (e.g., `backend.github`, `backend.postgres`). - -### Scopes - -| Scope | Library Location | Credentials Location | Use Case | -|-------|-----------------|---------------------|----------| -| **Project** (default) | `~/.devsync/library/` | `.devsync/.env` | Project-specific servers | -| **Global** | `~/.devsync/library/global/` | `~/.devsync/global/.env` | Personal or company-wide servers | - -```bash -# Install at global scope -devsync mcp install https://github.com/acme/mcp-servers --as company --scope global -``` - -When syncing, project credentials take precedence over global credentials for the same variable. - -## Step 3: Configure Credentials - -### Interactive Mode - -```bash -# Configure all servers in a namespace -devsync mcp configure backend - -# Configure a specific server -devsync mcp configure backend.github -``` - -The CLI prompts for each required credential: - -``` -Configuring MCP server: backend.github -Required environment variables: 1 - -Enter value for GITHUB_PERSONAL_ACCESS_TOKEN: - GITHUB_PERSONAL_ACCESS_TOKEN: **** - -Credentials saved to: /home/dev/my-project/.devsync/.env -(This file is automatically gitignored) -``` - -### Non-Interactive Mode - -For CI/CD or scripted setups: - -```bash -export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxx -export DATABASE_URL=postgresql://localhost/mydb -devsync mcp configure backend --non-interactive -``` - -### Viewing Current Credentials - -```bash -devsync mcp configure backend.github --show-current -``` - -Output shows masked values: - -``` -Current Credentials (project scope) -Server Variable Value Status -backend.github GITHUB_PERSONAL_ACCESS_TOKEN ****abc123 configured -``` - -### Where Credentials Are Stored - -Credentials are written to `.devsync/.env` in the project root: - -```text title=".devsync/.env" -GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxxxx -DATABASE_URL=postgresql://user:pass@localhost/mydb -SLACK_BOT_TOKEN=xoxb-xxxxx -``` - -!!! warning "Gitignore" - DevSync automatically adds `.devsync/.env` to your `.gitignore`. Verify this is in place before committing: - - ```bash - git status - # .devsync/.env should NOT appear in untracked files - ``` - -!!! danger "Never commit credentials" - Do not put credential values in `templatekit.yaml`. Use `null` for required credentials and let each developer configure their own values locally. - -## Step 4: Sync to AI Tools - -```bash -# Sync to all detected AI tools -devsync mcp sync --tool all - -# Sync to a specific tool -devsync mcp sync --tool claude -devsync mcp sync --tool cursor -devsync mcp sync --tool windsurf -``` - -Output: - -``` -Syncing MCP servers to AI tools -Scope: project -Tools: all - -Synced to 3 tool(s): - claude, cursor, copilot - -Server Summary: - Synced: 3 server(s) - Skipped: 1 server(s) - -Skipped Servers: - backend.postgres -- Missing credentials: DATABASE_URL - -Tip: Run 'devsync mcp configure backend' to configure missing credentials -``` - -### Dry Run - -Preview changes without writing any files: - -```bash -devsync mcp sync --tool all --dry-run -``` - -### Skip Backup - -By default, DevSync backs up existing config files before modifying them. To skip: - -```bash -devsync mcp sync --tool all --no-backup -``` - -See [Backups & Recovery](backups-and-recovery.md) for more on the backup system. - -## Managing Installed Servers - -### List - -```bash -# List all installed MCP templates -devsync mcp list - -# Filter by namespace -devsync mcp list backend - -# JSON output -devsync mcp list --json -``` - -### Update - -```bash -# Update a specific namespace (pulls latest from Git) -devsync mcp update backend - -# Update all -devsync mcp update --all -``` - -Updates pull latest changes from the source repository. Your local credentials are preserved. - -### Uninstall - -```bash -devsync mcp uninstall backend -``` - -This removes the template from your library. It does not remove credentials or already-synced configurations from AI tools. - -### Validate - -```bash -# Check if all required credentials are configured -devsync mcp validate -devsync mcp validate backend -``` diff --git a/docs/mcp-server/team-profiles.md b/docs/mcp-server/team-profiles.md deleted file mode 100644 index 44b79d9..0000000 --- a/docs/mcp-server/team-profiles.md +++ /dev/null @@ -1,167 +0,0 @@ -# Team Profiles - -Team profiles let you define standardized configurations that apply across an entire team or organization. A `devsync-profile.yaml` file specifies which templates, MCP servers, packages, and settings each role should use. - -!!! note "Forward-looking feature" - Team profiles are in the design phase. The schema and commands described here represent the planned direction. Implementation details may change. - ---- - -## The Problem - -As teams scale, configuration management becomes fragmented: - -- Different developers install different subsets of instructions -- New hires miss critical MCP server configs -- Roles with different needs (backend, frontend, SRE) need different tool setups -- There is no single source of truth for "what should a backend developer have installed?" - -## The Solution - -A `devsync-profile.yaml` file in your team's config repository defines roles and their required configurations. DevSync reads this profile and installs everything a developer needs based on their role. - -```bash -# Apply a team profile -devsync profile apply --role backend - -# Check compliance with your profile -devsync profile check -``` - ---- - -## Profile Schema - -```yaml title="devsync-profile.yaml" -name: Acme Engineering -version: 1.0.0 -description: Standard development configurations for Acme Corp - -defaults: - templates: - - repo: https://github.com/acme/coding-standards - namespace: acme - mcp_servers: - - repo: https://github.com/acme/mcp-shared - namespace: shared - packages: - - repo: https://github.com/acme/base-package - -roles: - backend: - description: Backend engineers working on APIs and services - extends: [] - templates: - - repo: https://github.com/acme/backend-standards - namespace: backend - mcp_servers: - - repo: https://github.com/acme/mcp-backend - namespace: backend - servers: [postgres, redis, github] - packages: - - repo: https://github.com/acme/python-dev-setup - - frontend: - description: Frontend engineers working on web applications - extends: [] - templates: - - repo: https://github.com/acme/frontend-standards - namespace: frontend - mcp_servers: - - repo: https://github.com/acme/mcp-frontend - namespace: frontend - servers: [github, figma, storybook] - - sre: - description: Site reliability engineers - extends: [backend] - templates: - - repo: https://github.com/acme/sre-standards - namespace: sre - mcp_servers: - - repo: https://github.com/acme/mcp-infra - namespace: infra - servers: [aws, terraform, datadog] - -settings: - require_mcp_credentials: true - auto_sync_on_install: true - backup_before_apply: true -``` - -### Schema Reference - -**Top-level fields** - -| Field | Type | Required | Description | -|-------|------|:--------:|-------------| -| `name` | `string` | Yes | Organization or team name | -| `version` | `string` | Yes | Profile version (semver) | -| `description` | `string` | No | What this profile covers | -| `defaults` | `object` | No | Configurations applied to all roles | -| `roles` | `object` | Yes | Role definitions (keyed by role name) | -| `settings` | `object` | No | Profile-wide settings | - -**Role fields** - -| Field | Type | Required | Description | -|-------|------|:--------:|-------------| -| `description` | `string` | No | What this role is for | -| `extends` | `list[string]` | No | Other roles to inherit from | -| `templates` | `list[object]` | No | Template repositories to install | -| `mcp_servers` | `list[object]` | No | MCP server repositories and selected servers | -| `packages` | `list[object]` | No | Packages to install | - ---- - -## Planned Commands - -```bash -# Apply a profile to the current project -devsync profile apply --role backend - -# Apply from a specific profile file -devsync profile apply --role backend --profile ./devsync-profile.yaml - -# Check if current project matches the profile -devsync profile check - -# Check a specific role -devsync profile check --role sre - -# List available roles -devsync profile roles -``` - -### Example: Applying a Profile - -``` -$ devsync profile apply --role backend - -Applying profile: Acme Engineering v1.0.0 -Role: backend - -Installing defaults... - Template: acme/coding-standards -> namespace 'acme' - MCP: acme/mcp-shared -> namespace 'shared' - Package: acme/base-package - -Installing role-specific configs... - Template: acme/backend-standards -> namespace 'backend' - MCP: acme/mcp-backend -> namespace 'backend' (postgres, redis, github) - Package: acme/python-dev-setup - -Configure credentials: - 3 MCP servers require credentials - Run: devsync mcp configure backend - -Applied 3 templates, 4 MCP servers, 2 packages -``` - ---- - -## Inheritance - -Roles can extend other roles using the `extends` field. The SRE role in the example above extends `backend`, meaning an SRE gets everything a backend developer gets, plus SRE-specific additions. - -Inheritance is additive. If the same namespace appears in both a parent and child role, the child's definition takes precedence. diff --git a/docs/mcp-server/tools-reference.md b/docs/mcp-server/tools-reference.md deleted file mode 100644 index 970b211..0000000 --- a/docs/mcp-server/tools-reference.md +++ /dev/null @@ -1,193 +0,0 @@ -# devsync-mcp Tools Reference - -`devsync-mcp` is a separate Python package that exposes DevSync operations as MCP tools. AI assistants connected to this server can read your current configurations, detect conflicts, and perform intelligent merges. - -```bash -pip install devsync-mcp -``` - -!!! info "Early-stage package" - `devsync-mcp` is under active development. The tools listed below represent the planned API surface. Specific parameter names, return types, and behavior may change as the package matures. This reference will be updated as the API stabilizes. - ---- - -## Server Configuration - -Register `devsync-mcp` with your AI tool like any other MCP server. - -=== "Claude Code" - - ```json title=".claude/settings.local.json" - { - "mcpServers": { - "devsync": { - "command": "uvx", - "args": ["devsync-mcp"], - "env": {} - } - } - } - ``` - -=== "Cursor" - - ```json title=".cursor/mcp.json" - { - "mcpServers": { - "devsync": { - "command": "uvx", - "args": ["devsync-mcp"], - "env": {} - } - } - } - ``` - -=== "Claude Desktop" - - ```json title="claude_desktop_config.json" - { - "mcpServers": { - "devsync": { - "command": "uvx", - "args": ["devsync-mcp"], - "env": {} - } - } - } - ``` - ---- - -## Available Tools - -### `devsync_read_config` - -Read the current DevSync configuration for a project. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `project_path` | `string` | Yes | Path to the project root | -| `scope` | `string` | No | `"project"` or `"global"`. Default: `"project"` | - -**Returns:** - -Project configuration including installed instructions, MCP servers, packages, and their current state. - ---- - -### `devsync_list_instructions` - -List all instructions available in the local library. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `namespace` | `string` | No | Filter by namespace | -| `tags` | `list[string]` | No | Filter by tags | - -**Returns:** - -List of available instructions with metadata (name, description, tags, source repository). - ---- - -### `devsync_compare_configs` - -Compare current project configuration against incoming changes from a template or package. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `project_path` | `string` | Yes | Path to the project root | -| `source` | `string` | Yes | Template namespace or package path to compare against | -| `component_type` | `string` | No | Filter by component type: `"instructions"`, `"mcp"`, `"hooks"`, `"commands"` | - -**Returns:** - -Diff-style comparison showing additions, removals, modifications, and conflicts. - ---- - -### `devsync_merge_config` - -Apply a merge strategy to resolve configuration differences. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `project_path` | `string` | Yes | Path to the project root | -| `source` | `string` | Yes | Template namespace or package path | -| `strategy` | `string` | No | `"skip"`, `"overwrite"`, or `"rename"`. Default: `"skip"` | -| `dry_run` | `boolean` | No | Preview changes without applying. Default: `false` | - -**Returns:** - -Merge result including applied changes, skipped items, and any remaining conflicts. - ---- - -### `devsync_list_mcp_servers` - -List MCP server configurations from the local library. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `namespace` | `string` | No | Filter by namespace | -| `include_credentials` | `boolean` | No | Include credential status (configured/missing). Default: `false` | - -**Returns:** - -List of MCP servers with their command, args, required credentials, and configuration status. - ---- - -### `devsync_sync_status` - -Check the sync status between library and installed configurations. - -**Parameters:** - -| Name | Type | Required | Description | -|------|------|:--------:|-------------| -| `project_path` | `string` | Yes | Path to the project root | -| `tool` | `string` | No | Specific AI tool to check, or `"all"` | - -**Returns:** - -Per-tool sync status showing which servers are synced, outdated, or missing. - ---- - -## Error Handling - -All tools return structured errors with a `code` and `message` field when operations fail. - -| Error Code | Meaning | -|-----------|---------| -| `PROJECT_NOT_FOUND` | The specified project path does not exist or has no DevSync configuration | -| `NAMESPACE_NOT_FOUND` | The requested namespace is not installed in the library | -| `CREDENTIALS_MISSING` | Required credentials have not been configured | -| `MERGE_CONFLICT` | Automatic merge failed and requires manual resolution | - - - ---- - -## Roadmap - -Planned additions to the `devsync-mcp` tool surface: - -- **`devsync_install_package`** -- Install a package directly from an AI conversation -- **`devsync_create_backup`** -- Trigger a manual backup of current configurations -- **`devsync_restore_backup`** -- Restore from a specific backup timestamp -- **`devsync_validate_manifest`** -- Validate a `templatekit.yaml` or `ai-config-kit-package.yaml` file - -Check the [devsync-mcp changelog](https://github.com/troylar/devsync-mcp/releases) for the latest updates. diff --git a/docs/packages/components.md b/docs/packages/components.md index 148cf27..3f1a007 100644 --- a/docs/packages/components.md +++ b/docs/packages/components.md @@ -1,60 +1,70 @@ # Component Types -This page documents each component type that a package can contain: the manifest entry format, the file format, and how the component is installed. +This page documents each component type that a package can contain: the manifest format, file format, and how the component is installed. -## Instructions +## Practices (v2) -Instructions are Markdown files that guide AI coding assistant behavior. They are the most widely supported component type -- every IDE that DevSync supports can use instructions. +Practices are abstract declarations of coding standards. They are the primary component type in v2 packages and are processed by AI during extraction and installation. -### Manifest Entry +### v2 Manifest Entry ```yaml -components: - instructions: - - name: code-quality - file: instructions/code-quality.md - description: Code quality and review guidelines - tags: [quality, review, best-practices] - ide_support: null # null or omitted = all IDEs +practices: + - name: type-safety + intent: Enforce strict type annotations + principles: + - All functions must have type hints + - Use modern syntax (list[str] not List[str]) + enforcement_patterns: + - Run mypy in strict mode + examples: + - "def process(items: list[str]) -> int: ..." + tags: [python, types] ``` | Field | Required | Description | |-------|----------|-------------| -| `name` | Yes | Unique identifier within the package | -| `file` | Yes | Relative path to the .md file | -| `description` | Yes | What this instruction covers | -| `tags` | No | Searchable categorization tags | -| `ide_support` | No | Restrict to specific IDEs (omit for all) | +| `name` | Yes | Short identifier (e.g., `type-safety`) | +| `intent` | Yes | One-line description of what it enforces | +| `principles` | Yes | List of rules and guidelines | +| `enforcement_patterns` | No | How to enforce (CI, linting, etc.) | +| `examples` | No | Code examples demonstrating the practice | +| `tags` | No | Categorization tags | -### File Format +### How Practices Are Installed -Plain Markdown. Write clear, actionable guidelines: +With AI enabled, practices are adapted to the target project's existing rules: -```markdown -# Code Quality Guidelines +- If no existing rule covers the same topic, a new file is created +- If an existing rule overlaps, the AI merges the practice into it +- Each IDE gets its own adapted version in the correct format -## Naming Conventions +Without AI (`--no-ai`), the raw practice content is written as a markdown file to each tool's directory. -- Variables and functions: `snake_case` -- Classes: `PascalCase` -- Constants: `UPPER_CASE` -- Use descriptive names that reveal intent +--- -## Error Handling +## Instructions (v1) -- Handle errors explicitly at the call site -- Use custom exceptions for domain errors -- Never swallow exceptions silently -- Log errors with sufficient context for debugging +Instructions are Markdown files that guide AI coding assistant behavior. They are the v1 equivalent of practices -- raw file content rather than abstract declarations. -## Code Organization +### Manifest Entry -- One responsibility per function -- Maximum 3-4 parameters per function -- Use early returns to reduce nesting -- Group related functions in the same module +```yaml +components: + instructions: + - name: code-quality + file: instructions/code-quality.md + description: Code quality and review guidelines + tags: [quality, review] ``` +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique identifier within the package | +| `file` | Yes | Relative path to the .md file | +| `description` | Yes | What this instruction covers | +| `tags` | No | Searchable categorization tags | + ### Installation Paths | IDE | Installed To | Extension | @@ -74,7 +84,21 @@ Plain Markdown. Write clear, actionable guidelines: MCP (Model Context Protocol) server configurations define external tool integrations. Each MCP component is a JSON file that tells the IDE how to launch and connect to an MCP server. -### Manifest Entry +### v2 Manifest Entry + +```yaml +mcp_servers: + - name: github + description: GitHub API access + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + credentials: + - name: GITHUB_TOKEN + description: GitHub personal access token + required: true +``` + +### v1 Manifest Entry ```yaml components: @@ -84,77 +108,28 @@ components: description: Local filesystem access via MCP credentials: - name: ALLOWED_DIRECTORIES - description: Comma-separated list of directories to expose + description: Directories to expose required: false default: "." - - name: GITHUB_TOKEN - description: GitHub personal access token - required: true - example: "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - ide_support: [claude, windsurf] ``` -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Server identifier | -| `file` | Yes | Relative path to .json config | -| `description` | Yes | What the server provides | -| `credentials` | No | Environment variables needed | -| `ide_support` | No | IDEs that support this server | - ### Credential Descriptors Each credential entry describes an environment variable: -```yaml -credentials: - - name: API_KEY # UPPER_SNAKE_CASE, required - description: Service API key - required: true # true = must be provided (default) - default: null # Cannot have default if required - example: "sk-abc123..." # Example value for documentation - - name: LOG_LEVEL - description: Server log verbosity - required: false - default: "info" # Default used when not provided -``` - | Field | Required | Description | |-------|----------|-------------| | `name` | Yes | Env var name (UPPER_SNAKE_CASE) | | `description` | Yes | What this credential is for | | `required` | No | Whether user must provide it (default: true) | | `default` | No | Default value (only valid when required=false) | -| `example` | No | Example value for user reference | !!! note "Validation" - A credential cannot be both `required: true` and have a `default` value. The manifest parser enforces this. - -### File Format - -Standard MCP server configuration JSON: - -```json -{ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-filesystem", - "${ALLOWED_DIRECTORIES:-.}" - ], - "env": {} - } - } -} -``` - -Use `${VAR_NAME}` placeholders for values that should be configured per-installation. The `${VAR:default}` syntax provides a fallback. + A credential cannot be both `required: true` and have a `default` value. ### Supported IDEs -Claude Code, Cursor, Windsurf, Roo Code, GitHub Copilot, Antigravity, Amazon Q, JetBrains AI, Zed, Continue.dev, Trae, Augment, Tabnine, and OpenHands. +Claude Code, Cursor, Windsurf, Roo Code, GitHub Copilot, and others. See [IDE Compatibility](index.md#ide-compatibility). --- @@ -169,9 +144,8 @@ components: hooks: - name: pre-commit file: hooks/pre-commit.sh - description: Run linting and formatting before commits + description: Run linting before commits hook_type: pre-commit - ide_support: [claude] ``` | Field | Required | Description | @@ -179,47 +153,7 @@ components: | `name` | Yes | Hook identifier | | `file` | Yes | Relative path to shell script | | `description` | Yes | What the hook does | -| `hook_type` | Yes | Trigger type (e.g., `pre-commit`, `post-install`) | -| `ide_support` | No | IDEs that support hooks (default: `[claude]`) | - -### File Format - -Executable shell script: - -```bash -#!/usr/bin/env bash -# Pre-commit hook: run linting and formatting checks - -set -e - -echo "Running pre-commit checks..." - -# Check formatting -if command -v black &> /dev/null; then - echo "-> Checking formatting (black)..." - black --check . || { - echo "Formatting issues found. Run 'black .' to fix." - exit 1 - } -fi - -# Check linting -if command -v ruff &> /dev/null; then - echo "-> Checking linting (ruff)..." - ruff check . || { - echo "Linting issues found. Run 'ruff check --fix .' to fix." - exit 1 - } -fi - -echo "All pre-commit checks passed." -``` - -!!! tip "Script requirements" - - Include `#!/usr/bin/env bash` shebang - - Use `set -e` to fail on errors - - Check for required tools before using them - - DevSync sets the executable bit (0o755) automatically during installation +| `hook_type` | Yes | Trigger type (e.g., `PreToolUse`, `PostToolUse`) | ### Installation Path @@ -231,7 +165,7 @@ echo "All pre-commit checks passed." ## Commands -Commands are shell scripts that users can invoke on demand, typically via slash commands in the IDE. +Commands are shell scripts or slash commands that users invoke on demand. ### Manifest Entry @@ -242,7 +176,6 @@ components: file: commands/test.sh description: Run test suite with coverage command_type: shell - ide_support: [claude, roo] ``` | Field | Required | Description | @@ -251,31 +184,6 @@ components: | `file` | Yes | Relative path to script | | `description` | Yes | What the command does | | `command_type` | Yes | Type: `shell` or `slash` | -| `ide_support` | No | IDEs that support commands | - -### File Format - -```bash -#!/usr/bin/env bash -# Run pytest with coverage reporting - -set -e - -if ! command -v pytest &> /dev/null; then - echo "pytest not found. Install with: pip install pytest pytest-cov" - exit 1 -fi - -pytest \ - --cov=. \ - --cov-report=term \ - --cov-report=html \ - -v \ - "$@" - -echo "" -echo "Coverage report: htmlcov/index.html" -``` ### Installation Paths @@ -286,183 +194,6 @@ echo "Coverage report: htmlcov/index.html" --- -## Skills - -Skills are Claude Code-specific directories that contain a `SKILL.md` file and optional supporting files. Skills can be invoked via slash commands and are shared across Claude products. - -### Manifest Entry - -```yaml -components: - skills: - - name: deploy - file: skills/deploy - description: Automated deployment to staging and production - ide_support: [claude] -``` - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Skill identifier (becomes directory name) | -| `file` | Yes | Relative path to skill directory | -| `description` | Yes | What the skill does | -| `ide_support` | No | IDEs that support skills (default: `[claude]`) | - -### File Format - -A skill is a directory containing at minimum a `SKILL.md` file: - -``` -skills/deploy/ -├── SKILL.md # Required: skill definition -├── deploy.sh # Optional: supporting script -└── config.yaml # Optional: supporting config -``` - -The `SKILL.md` file uses frontmatter to declare the skill metadata: - -```markdown ---- -name: deploy -description: Deploy the application to staging or production ---- - -# Deploy Skill - -Deploy the current project to the specified environment. - -## Usage - -Invoke with `/deploy staging` or `/deploy production`. - -## Steps - -1. Run the test suite -2. Build the application -3. Push to the target environment -4. Verify the deployment health check -``` - -### Installation Path - -| IDE | Installed To | -|-----|-------------| -| Claude Code | `.claude/skills//` (entire directory copied) | - ---- - -## Workflows - -Workflows are Windsurf-specific files that define multi-step automated processes. - -### Manifest Entry - -```yaml -components: - workflows: - - name: code-review - file: workflows/code-review.md - description: Automated code review workflow - ide_support: [windsurf] -``` - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Workflow identifier | -| `file` | Yes | Relative path to workflow file | -| `description` | Yes | What the workflow does | -| `ide_support` | No | IDEs that support workflows (default: `[windsurf]`) | - -### File Format - -Markdown file defining the workflow steps: - -```markdown -# Code Review Workflow - -## Trigger -Run on pull request creation or update. - -## Steps - -1. Check for test coverage on changed files -2. Review code style compliance -3. Identify potential security issues -4. Suggest performance improvements -5. Generate review summary -``` - -### Installation Path - -| IDE | Installed To | -|-----|-------------| -| Windsurf | `.windsurf/workflows/.md` | - ---- - -## Memory Files - -Memory files are `CLAUDE.md` files that persist context across Claude Code sessions. They can exist at the project root or in subdirectories to scope context to specific areas of the codebase. - -### Manifest Entry - -```yaml -components: - memory_files: - - name: project-context - file: memory_files/CLAUDE.md - description: Project architecture and conventions - ide_support: [claude] - - name: api-context - file: memory_files/src/api/CLAUDE.md - description: API layer conventions and patterns - ide_support: [claude] -``` - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Memory file identifier | -| `file` | Yes | Relative path within package | -| `description` | Yes | What context this file provides | -| `ide_support` | No | IDEs that support memory files (default: `[claude]`) | - -### File Format - -Standard Markdown. The content should provide persistent context: - -```markdown -# Project Context - -## Architecture - -This project uses a layered architecture: -- `src/api/` -- HTTP handlers and route definitions -- `src/core/` -- Business logic, independent of transport -- `src/storage/` -- Database access and persistence -- `src/utils/` -- Shared utilities - -## Conventions - -- All functions must have type hints -- Use dataclasses for data models -- Prefer composition over inheritance -- Tests go in `tests/` mirroring the `src/` structure - -## Active Decisions - -- Using SQLAlchemy 2.0 with async sessions -- Pydantic v2 for request/response validation -- pytest with fixtures for all database tests -``` - -### Installation Path - -| IDE | Installed To | -|-----|-------------| -| Claude Code | `CLAUDE.md` at project root (for root memory files) or subdirectory paths preserved | - ---- - ## Resources Resources are arbitrary files -- configuration templates, .gitignore files, editor configs, or any other file that should be part of the project setup. @@ -485,40 +216,11 @@ components: | `name` | Yes | Resource identifier | | `file` | Yes | Relative path in package | | `description` | Yes | What the resource is | -| `install_path` | Yes | Where to install in the project (relative to project root) | -| `checksum` | Yes | SHA-256 checksum for integrity (`sha256:...`) | +| `install_path` | Yes | Where to install relative to project root | +| `checksum` | Yes | SHA-256 checksum for integrity | | `size` | Yes | File size in bytes | -### File Format - -Any file type. Resources are copied verbatim (both text and binary files are supported): - -```gitignore -# Python -__pycache__/ -*.py[cod] -*.so -.Python -env/ -venv/ -*.egg-info/ -dist/ -build/ - -# Testing -.coverage -htmlcov/ -.pytest_cache/ - -# IDEs -.vscode/ -.idea/ -*.swp -``` - -### Installation Path - -Resources are installed to the path specified by `install_path`, relative to the project root. If `install_path` is not specified, it defaults to the `file` path. +Resources are copied verbatim (both text and binary files are supported). | IDE | Supported | |-----|-----------| diff --git a/docs/packages/creating.md b/docs/packages/creating.md index 78f187f..9ddf2e8 100644 --- a/docs/packages/creating.md +++ b/docs/packages/creating.md @@ -1,403 +1,227 @@ # Creating Packages -## Manual Package Creation +## Using `devsync extract` (Recommended) -A package requires two things: an `ai-config-kit-package.yaml` manifest and the component files it references. +The easiest way to create a package is to extract practices from an existing project: -### Manifest Format - -The manifest declares package metadata and lists all components. - -#### Required Fields - -```yaml -name: my-package # Lowercase alphanumeric with hyphens -version: 1.0.0 # Semantic versioning (X.Y.Z) -description: What this package does -author: Your Name -license: MIT -namespace: org/repo # Unique namespace identifier +```bash +cd ~/my-project +devsync extract --output ./team-standards --name team-standards ``` -| Field | Rules | -|-------|-------| -| `name` | Lowercase letters, numbers, hyphens, underscores only | -| `version` | Must follow semver: `MAJOR.MINOR.PATCH` with optional pre-release suffix | -| `description` | Free text | -| `author` | Free text | -| `license` | License identifier (MIT, Apache-2.0, etc.) | -| `namespace` | Typically `owner/repo` format | +DevSync reads the project's AI rules (`.claude/rules/`, `.cursor/rules/`, etc.), MCP configurations, and other tool-specific files. It produces a v2 package with abstract practice declarations. -#### Optional Fields +### What Gets Extracted -These fields are not currently parsed by the manifest parser but are useful for documentation: +| Source | Locations Scanned | +|--------|------------------| +| Rules/Instructions | `.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` | + +### Output Structure -```yaml -author_email: team@example.com -homepage: https://example.com -repository: https://github.com/org/repo -keywords: [python, testing, security] +``` +team-standards/ +├── devsync-package.yaml # Package manifest +├── practices/ # Practice declaration files +│ ├── type-safety.md +│ ├── error-handling.md +│ └── code-style.md +└── mcp/ # MCP server configs (if found) + └── github.json ``` -#### Components Section +### File-Copy Mode -The `components` section contains named lists for each component type: +Extract without AI processing: -```yaml -components: - instructions: - - name: code-style - file: instructions/code-style.md - description: Code style guidelines - tags: [style, quality] +```bash +devsync extract --output ./pkg --name my-pkg --no-ai +``` - mcp_servers: - - name: filesystem - file: mcp/filesystem.json - description: Filesystem access server - credentials: - - name: ALLOWED_DIRECTORIES - description: Directories the server can access - required: false - default: "." +Source files are copied verbatim instead of being converted to practice declarations. - hooks: - - name: pre-commit - file: hooks/pre-commit.sh - description: Run checks before commits - hook_type: pre-commit +### Upgrading v1 Packages - commands: - - name: test - file: commands/test.sh - description: Run test suite - command_type: shell +Convert an existing v1 package to v2 format: - skills: - - name: deploy - file: skills/deploy - description: Deployment automation skill +```bash +devsync extract --upgrade ./old-v1-package --output ./v2-package --name my-package +``` - workflows: - - name: review - file: workflows/review.md - description: Code review workflow +## v2 Package Manifest - memory_files: - - name: project-context - file: memory_files/CLAUDE.md - description: Project context and conventions +The `devsync-package.yaml` manifest declares package metadata and practice declarations: - resources: - - name: gitignore - file: resources/.gitignore - description: Standard gitignore - install_path: .gitignore - checksum: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - size: 250 +```yaml +name: team-standards +version: 1.0.0 +description: Team coding standards and MCP configurations + +practices: + - name: type-safety + intent: Enforce strict type annotations in all code + principles: + - All functions must have type hints for parameters and return values + - Use modern Python syntax (list[str] not List[str]) + - Prefer strict mypy configuration + enforcement_patterns: + - Run mypy in strict mode in CI + - Use ruff rules for type annotation enforcement + examples: + - "def process(items: list[str]) -> dict[str, int]: ..." + tags: [python, types, quality] + + - name: error-handling + intent: Structured error handling with custom exceptions + principles: + - Use custom exception classes for domain errors + - Handle errors at the appropriate level + - Never swallow exceptions silently + enforcement_patterns: + - Code review checklist includes error handling + tags: [python, errors] + +mcp_servers: + - name: github + description: GitHub API access for PRs and issues + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + credentials: + - name: GITHUB_TOKEN + description: GitHub personal access token + required: true ``` -### Complete Example Manifest +## v1 Package Manifest (Backward Compatible) -This manifest demonstrates all component types in a single package: +DevSync v2 still supports v1 `ai-config-kit-package.yaml` manifests. These use a `components` section with file references: ```yaml name: python-dev-setup -version: 2.0.0 -description: Python development environment with linting, testing, and MCP tools -author: Engineering Team +version: 1.0.0 +description: Python development configuration +author: Platform Team license: MIT -namespace: acme/python-packages +namespace: platform/python components: instructions: - name: python-style file: instructions/python-style.md - description: PEP 8 guidelines with project-specific conventions - tags: [python, style, pep8] - - - name: testing-strategy - file: instructions/testing-strategy.md - description: pytest patterns and coverage requirements - tags: [python, testing, pytest] + description: PEP 8 guidelines + tags: [python, style] mcp_servers: - name: filesystem file: mcp/filesystem.json - description: Filesystem access for project files + description: Filesystem access server credentials: - name: ALLOWED_DIRECTORIES - description: Comma-separated directories to allow access + description: Directories the server can access required: false default: "." hooks: - name: pre-commit file: hooks/pre-commit.sh - description: Run black, ruff, and mypy before commits + description: Run checks before commits hook_type: pre-commit commands: - name: test file: commands/test.sh - description: Run pytest with coverage reporting - command_type: shell - - - name: lint - file: commands/lint.sh - description: Run ruff with auto-fix + description: Run test suite command_type: shell resources: - name: gitignore file: resources/.gitignore - description: Python-specific gitignore + description: Standard gitignore install_path: .gitignore - checksum: sha256:abc123... - size: 450 ``` -### Directory Structure - -After creating the manifest, create directories and files to match: - -``` -python-dev-setup/ -├── ai-config-kit-package.yaml -├── README.md -├── instructions/ -│ ├── python-style.md -│ └── testing-strategy.md -├── mcp/ -│ └── filesystem.json -├── hooks/ -│ └── pre-commit.sh -├── commands/ -│ ├── test.sh -│ └── lint.sh -└── resources/ - └── .gitignore -``` - -### Validation - -The manifest parser validates: - -- All required fields are present -- Version follows semantic versioning -- Every `file` reference points to an existing file in the package directory -- Component names are unique within each type +## Testing a Package -If validation fails, `devsync package install` reports the specific errors: +After creating a package, verify it works: -``` -Error: Manifest validation failed: Instruction file not found: instructions/missing.md -``` +```bash +# Create a temporary test project +mkdir /tmp/test-project && cd /tmp/test-project +git init -## Creating Packages from Existing Projects +# Install the package +devsync install /path/to/my-package -The `devsync package create` command scans a project for existing AI configurations and generates a package automatically. +# Check what was installed +devsync list -### Basic Usage +# Verify files exist +ls -la .claude/rules/ +ls -la .cursor/rules/ -```bash -devsync package create --name my-package +# Clean up +devsync uninstall my-package --force ``` -This scans the current project for: +## Distributing Packages -- Instructions in `.claude/rules/`, `.cursor/rules/`, `.windsurf/rules/`, etc. -- MCP server configs in `.claude/settings.local.json` -- Hooks in `.claude/hooks/` -- Commands in `.claude/commands/` -- Skills in `.claude/skills/` -- Workflows in `.windsurf/workflows/` -- Memory files (CLAUDE.md) -- Resources in `.devsync/resources/` +### Via Git -### Command Options +Push your package directory to a Git repository: ```bash -devsync package create \ - --name my-package \ # Package name (required in non-interactive mode) - --version 1.0.0 \ # Version (default: 1.0.0) - --description "Description" \ # Package description - --author "Your Name" \ # Author (default: git user.name) - --license MIT \ # License (default: MIT) - --output ./packages \ # Output directory (default: current dir) - --project ~/my-project \ # Project to scan (default: current dir) - --no-interactive \ # Skip prompts - --scrub-secrets \ # Template secrets in MCP configs (default) - --keep-secrets \ # Preserve secrets as-is - --force \ # Overwrite existing package directory - --json # Output results as JSON +cd team-standards +git init && git add . && git commit -m "Initial package" +git remote add origin https://github.com/company/team-standards +git push -u origin main ``` -### Interactive Mode - -By default, `package create` runs interactively: +Others install with: ```bash -$ devsync package create - -Scanning project: /home/user/my-project - -Detected components: -┏━━━━━━━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ Type ┃ Count┃ Details ┃ -┡━━━━━━━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ Instructions│ 3│ code-style, testing, security │ -│ MCP Servers │ 1│ filesystem │ -│ Hooks │ 2│ pre-commit, post-checkout │ -│ Commands │ 1│ test │ -└─────────────┴──────┴──────────────────────────────────┘ - -Total: 7 component(s) - -Package name [my-project]: my-team-config -Package description [Configuration package for my-project]: Team dev standards -Creating package with 7 components -Proceed? [Y/n]: y - -Package created successfully! - Location: /home/user/my-project/package-my-team-config - Components: 7 - Secrets templated: 2 +devsync install https://github.com/company/team-standards ``` -### Non-Interactive Mode +### Via Shared Directory -For CI/CD or scripting: +Copy the package directory to a shared location (NFS, Dropbox, etc.): ```bash -devsync package create \ - --name ci-package \ - --description "Auto-generated package" \ - --no-interactive \ - --json -``` - -## Secret Detection and Templating - -When creating packages from existing projects, MCP server configurations often contain API keys, tokens, or other credentials. The `--scrub-secrets` flag (enabled by default) automatically detects and templates these values. - -### How Detection Works - -The secret detector uses three confidence levels: - -| Confidence | Action | Triggers | -|-----------|--------|----------| -| **HIGH** | Auto-template | Key names containing TOKEN, KEY, SECRET, PASSWORD, AUTH, API; JWT patterns; API key patterns (20+ alphanumeric chars) | -| **MEDIUM** | Auto-template | High-entropy values (>4.5 bits/char); ambiguous keys like `*_URL` with credentials | -| **SAFE** | Preserve | Booleans, version strings, short values (<8 chars), URLs without credentials, keys containing PATH, DIR, HOST, PORT | - -### Example - -Given this MCP configuration in a project: - -```json -{ - "mcpServers": { - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_TOKEN": "ghp_abc123def456ghi789jkl012mno345pqr678", - "GITHUB_HOST": "github.com" - } - } - } -} -``` - -After `devsync package create --scrub-secrets`, the packaged config becomes: - -```json -{ - "mcpServers": { - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_TOKEN": "${GITHUB_TOKEN}", - "GITHUB_HOST": "github.com" - } - } - } -} +cp -r ./team-standards /shared/packages/ ``` -The manifest also generates credential descriptors for templated values: - -```yaml -mcp_servers: - - name: github - file: mcp/github.json - description: GitHub MCP server - credentials: - - name: GITHUB_TOKEN - description: Environment variable for github - required: true -``` - -### Disabling Secret Scrubbing - -If you need to preserve values (for example, non-sensitive defaults): +Others install with: ```bash -devsync package create --name my-package --keep-secrets +devsync install /shared/packages/team-standards ``` -!!! warning "Security" - Only use `--keep-secrets` when you are certain no sensitive values exist in MCP configurations. The package directory will contain the original values verbatim. +### Inside a Project Repo -## Testing a Package - -After creating a package, verify it works: +Include the package as a subdirectory in an existing repository: ```bash -# Create a temporary test project -mkdir /tmp/test-project && cd /tmp/test-project -git init - -# Install the package -devsync package install /path/to/my-package --ide claude - -# Check what was installed -devsync package list - -# Verify files exist -ls -la .claude/rules/ -ls -la .claude/hooks/ -ls -la .claude/commands/ - -# Clean up -devsync package uninstall my-package --yes +my-project/ +├── src/ +├── tests/ +└── .devsync-package/ # Package for new contributors + └── devsync-package.yaml ``` -Test across multiple IDEs to verify filtering works correctly: +New contributors install with: ```bash -# Claude Code gets everything -devsync package install ./my-package --ide claude - -# Cursor gets only instructions, MCP, and resources -devsync package install ./my-package --ide cursor --project /tmp/test-cursor - -# Windsurf gets instructions, MCP, workflows, and resources -devsync package install ./my-package --ide windsurf --project /tmp/test-windsurf +devsync install ./.devsync-package ``` ## Best Practices **Keep packages focused.** A package for "Python development" is better than a package for "everything." Users can install multiple packages. -**Use semantic versioning.** Bump the patch version for typo fixes, minor for new components, major for breaking changes (renamed or removed files). - -**Write a README.** Include what the package does, what tools it requires, and how to customize it after installation. The `package create` command generates one automatically. - -**Tag components consistently.** Tags like `[python, testing, pytest]` are more useful than `[misc, important]`. +**Use semantic versioning.** Bump the patch version for typo fixes, minor for new practices, major for breaking changes. -**Make scripts portable.** Use `#!/usr/bin/env bash`, check for required commands before using them, and avoid platform-specific flags. +**Write a README.** Include what the package does, what tools it requires, and how to customize after installation. **Version control your packages.** A package directory works well as a Git repository. Tag releases to match the manifest version. diff --git a/docs/packages/examples.md b/docs/packages/examples.md index ddbe6df..63e75ef 100644 --- a/docs/packages/examples.md +++ b/docs/packages/examples.md @@ -1,329 +1,162 @@ # Package Examples -Four complete package examples covering common use cases. Each includes the full manifest, directory structure, and representative component files. +Complete package examples covering common use cases. --- -## 1. Python Development Setup +## 1. Team Standards (v2 AI-Native) -A package for Python projects that enforces coding standards with instructions, automates quality checks with hooks, and provides convenience commands. +A v2 package extracted from a project with established coding standards. Contains practice declarations that AI adapts to each recipient's setup. -### Directory Structure +### Creating the Package +```bash +cd ~/team-project +devsync extract --output ./team-standards --name team-standards ``` -python-dev-setup/ -├── ai-config-kit-package.yaml -├── instructions/ -│ ├── python-style.md + +### Output Structure + +``` +team-standards/ +├── devsync-package.yaml +├── practices/ +│ ├── code-style.md │ ├── testing-strategy.md │ └── error-handling.md -├── hooks/ -│ └── pre-commit.sh -├── commands/ -│ ├── test.sh -│ └── lint.sh -└── resources/ - └── .gitignore +└── mcp/ + └── github.json ``` ### Manifest ```yaml -name: python-dev-setup +name: team-standards version: 1.0.0 -description: Python development standards with linting, testing, and code quality -author: Platform Team -license: MIT -namespace: platform/python - -components: - instructions: - - name: python-style - file: instructions/python-style.md - description: PEP 8 conventions with project-specific rules - tags: [python, style, formatting] - - - name: testing-strategy - file: instructions/testing-strategy.md - description: pytest patterns, fixtures, and coverage requirements - tags: [python, testing, pytest] - - - name: error-handling - file: instructions/error-handling.md - description: Exception handling and error reporting patterns - tags: [python, errors, logging] - - hooks: - - name: pre-commit - file: hooks/pre-commit.sh - description: Run black, ruff, and mypy before every commit - hook_type: pre-commit - - commands: - - name: test - file: commands/test.sh - description: Run pytest with coverage and HTML report - command_type: shell - - - name: lint - file: commands/lint.sh - description: Run ruff with auto-fix enabled - command_type: shell - - resources: - - name: gitignore - file: resources/.gitignore - description: Python-specific gitignore - install_path: .gitignore - checksum: sha256:a1b2c3d4e5f6... - size: 320 -``` - -### Key Files - -**`instructions/python-style.md`** - -```markdown -# Python Style Guide - -## Formatting - -- Line length: 120 characters maximum -- Indentation: 4 spaces, no tabs -- Formatter: black (run automatically via pre-commit hook) -- Linter: ruff with rule sets E, F, I, N, W - -## Type Hints - -All functions must include type hints: - - def process_items(items: list[dict[str, str]], limit: int = 10) -> list[str]: - """Extract names from item dictionaries.""" - return [item["name"] for item in items[:limit]] - -Use modern syntax (Python 3.10+): `list[str]` not `List[str]`, -`str | None` not `Optional[str]`. - -## Import Order - -1. Standard library -2. Third-party packages -3. Local application modules - -Each group separated by a blank line. Use `ruff` to enforce ordering. - -## Naming - -| Element | Convention | Example | -|------------|------------------|--------------------| -| Variables | snake_case | user_count | -| Functions | snake_case | get_active_users | -| Classes | PascalCase | UserAccount | -| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT | -| Private | _leading_under | _validate_input | -``` - -**`hooks/pre-commit.sh`** - -```bash -#!/usr/bin/env bash -set -e - -echo "Running pre-commit checks..." - -if command -v black &> /dev/null; then - echo "-> black (formatting)" - black --check . || { echo "Run 'black .' to fix."; exit 1; } -fi - -if command -v ruff &> /dev/null; then - echo "-> ruff (linting)" - ruff check . || { echo "Run 'ruff check --fix .' to fix."; exit 1; } -fi - -if command -v mypy &> /dev/null; then - echo "-> mypy (type checking)" - mypy . || { echo "Fix type errors before committing."; exit 1; } -fi - -echo "All checks passed." +description: Team coding standards extracted from the main project + +practices: + - name: code-style + intent: Consistent code formatting and naming conventions + principles: + - Line length 120 characters maximum + - Use black for formatting, ruff for linting + - snake_case for functions and variables, PascalCase for classes + - Modern type hints (list[str] not List[str]) + enforcement_patterns: + - Pre-commit hooks run black and ruff + - CI pipeline includes format and lint checks + examples: + - "def process_items(items: list[dict[str, str]], limit: int = 10) -> list[str]:" + tags: [python, style, formatting] + + - name: testing-strategy + intent: Test-first development with pytest + principles: + - Write tests before implementation + - Use pytest fixtures, not setUp/tearDown + - Mock external dependencies in unit tests + - Minimum 80% coverage target + enforcement_patterns: + - CI pipeline runs pytest with coverage + - PRs blocked if coverage drops + tags: [python, testing, pytest] + + - name: error-handling + intent: Structured error handling with custom exceptions + principles: + - Use custom exception classes for domain errors + - Handle errors at the appropriate abstraction level + - Never swallow exceptions silently + - Log errors with context (user ID, request ID) + enforcement_patterns: + - Code review checklist includes error handling review + tags: [python, errors, logging] + +mcp_servers: + - name: github + description: GitHub API access for PRs and issues + command: npx + args: ["-y", "@modelcontextprotocol/server-github"] + credentials: + - name: GITHUB_TOKEN + description: GitHub personal access token with repo scope + required: true ``` ### Installation ```bash -devsync package install ./python-dev-setup --ide claude +# AI-powered installation (adapts to existing rules) +devsync install ./team-standards + +# Or from Git +devsync install https://github.com/company/team-standards ``` --- -## 2. Security Compliance Package +## 2. Security Compliance (v2 AI-Native) -A package focused on secure coding practices. Contains instructions covering OWASP guidelines and resource files for security tooling configuration. - -### Directory Structure - -``` -security-compliance/ -├── ai-config-kit-package.yaml -├── instructions/ -│ ├── secure-coding.md -│ ├── authentication.md -│ ├── input-validation.md -│ └── dependency-management.md -└── resources/ - ├── .snyk - └── security-checklist.md -``` +A security-focused package with practices covering OWASP guidelines. ### Manifest ```yaml name: security-compliance -version: 1.1.0 -description: OWASP-aligned secure coding guidelines and security tooling -author: Security Team -license: Apache-2.0 -namespace: security/compliance - -components: - instructions: - - name: secure-coding - file: instructions/secure-coding.md - description: Core secure coding principles (OWASP Top 10) - tags: [security, owasp, coding] - - - name: authentication - file: instructions/authentication.md - description: Authentication and session management standards - tags: [security, auth, sessions] - - - name: input-validation - file: instructions/input-validation.md - description: Input validation, sanitization, and encoding rules - tags: [security, validation, injection] - - - name: dependency-management - file: instructions/dependency-management.md - description: Dependency scanning and supply chain security - tags: [security, dependencies, sca] - - resources: - - name: snyk-config - file: resources/.snyk - description: Snyk vulnerability scanning configuration - install_path: .snyk - checksum: sha256:b2c3d4e5f6a7... - size: 180 - - - name: security-checklist - file: resources/security-checklist.md - description: Pre-deployment security review checklist - install_path: docs/security-checklist.md - checksum: sha256:c3d4e5f6a7b8... - size: 2400 -``` - -### Key Files - -**`instructions/secure-coding.md`** - -```markdown -# Secure Coding Standards - -## Principles - -1. Never trust user input. Validate on the server side. -2. Use parameterized queries for all database operations. Never concatenate - user input into SQL strings. -3. Encode output for its context (HTML, JavaScript, URL, SQL). -4. Use established authentication libraries. Do not implement custom auth. -5. Apply the principle of least privilege to all access control decisions. - -## Forbidden Patterns - -The following patterns must never appear in code: - -- `eval()`, `exec()`, `Function()` with user-controlled input -- SQL string concatenation: `f"SELECT * FROM users WHERE id = {user_id}"` -- `innerHTML` assignment with unsanitized data -- Hardcoded secrets, API keys, or credentials -- Disabled CSRF protection -- `verify=False` or `rejectUnauthorized: false` in production code -- Wildcard CORS: `Access-Control-Allow-Origin: *` - -## Error Handling - -- Return generic error messages to users -- Log detailed errors server-side with timestamps, user IDs, and source IPs -- Never expose stack traces, SQL errors, or internal paths in responses -- Never log passwords, tokens, or PII - -## Cryptography - -- Passwords: Argon2id, bcrypt (cost >= 12), or scrypt -- Symmetric encryption: AES-256-GCM or ChaCha20-Poly1305 -- TLS 1.2+ for all data in transit -- Generate keys and IVs from the platform CSPRNG -- Store secrets in environment variables or a secrets manager -``` - -**`instructions/input-validation.md`** - -```markdown -# Input Validation Rules - -## Server-Side Validation - -All input must be validated on the server. Client-side validation is for UX -only and provides no security benefit. - -## Validation Strategy - -Use allowlists (whitelists) over denylists: - - # Good: allowlist - ALLOWED_STATUSES = {"active", "inactive", "pending"} - if status not in ALLOWED_STATUSES: - raise ValidationError(f"Invalid status: {status}") - - # Bad: denylist - BLOCKED = {"admin", "root"} - if role in BLOCKED: - raise ValidationError("Blocked role") - -## Database Queries - -Always use parameterized queries: - - # Good: parameterized - cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) - - # Bad: string concatenation - cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") - -## File Uploads - -- Validate MIME type and extension against an allowlist -- Enforce maximum file size -- Store uploaded files outside the web root -- Generate random filenames; never use the original filename in the path +version: 1.0.0 +description: OWASP-aligned secure coding practices + +practices: + - name: input-validation + intent: Validate all input on the server side + principles: + - Use allowlists over denylists for validation + - Parameterized queries for all database operations + - Context-encode all output (HTML, JS, URL) + - Validate file uploads (MIME type, extension, size) + enforcement_patterns: + - SAST scanning in CI (Semgrep, CodeQL) + - Dependency vulnerability scanning (pip-audit, npm audit) + tags: [security, validation, owasp] + + - name: authentication + intent: Secure authentication and session management + principles: + - Use established auth frameworks, never roll your own + - Passwords stored with Argon2id or bcrypt (cost >= 12) + - Session IDs from CSPRNG with >= 128 bits entropy + - Rate limiting on login endpoints + enforcement_patterns: + - Security review required for auth changes + tags: [security, auth, sessions] + + - name: secrets-management + intent: No hardcoded secrets, credentials in env vars or secrets manager + principles: + - Never hardcode API keys, passwords, or tokens + - Use environment variables or secrets manager + - Never log credential values + - TLS 1.2+ for all data in transit + enforcement_patterns: + - Secret scanning in CI (gitleaks, trufflehog) + - Pre-commit hooks check for hardcoded patterns + tags: [security, secrets, credentials] ``` ### Installation ```bash -# Works with any IDE since it only contains instructions and resources -devsync package install ./security-compliance --ide claude -devsync package install ./security-compliance --ide cursor -devsync package install ./security-compliance --ide windsurf +# Works with any IDE +devsync install ./security-compliance +devsync install ./security-compliance --tool claude --tool cursor ``` --- -## 3. Full-Stack Team Package +## 3. Full-Stack Team (v1 Format) -A comprehensive package for a team working on a full-stack application. Includes instructions for frontend and backend, MCP servers for tooling, hooks for quality gates, commands for common workflows, and resource files. +A v1-format package that bundles instructions, MCP servers, hooks, commands, and resources. ### Directory Structure @@ -333,19 +166,16 @@ fullstack-team/ ├── instructions/ │ ├── api-design.md │ ├── react-components.md -│ ├── database-patterns.md -│ └── code-review.md +│ └── database-patterns.md ├── mcp/ │ ├── filesystem.json │ └── github.json ├── hooks/ │ └── pre-commit.sh ├── commands/ -│ ├── test-all.sh -│ └── dev.sh +│ └── test-all.sh └── resources/ - ├── .editorconfig - └── .prettierrc + └── .editorconfig ``` ### Manifest @@ -353,7 +183,7 @@ fullstack-team/ ```yaml name: fullstack-team version: 2.0.0 -description: Full-stack development environment with API, React, and database patterns +description: Full-stack development environment author: Engineering license: MIT namespace: acme/fullstack @@ -362,7 +192,7 @@ components: instructions: - name: api-design file: instructions/api-design.md - description: RESTful API design patterns and conventions + description: RESTful API design patterns tags: [api, rest, backend] - name: react-components @@ -375,29 +205,23 @@ components: description: Database schema design and query optimization tags: [database, sql, backend] - - name: code-review - file: instructions/code-review.md - description: Code review checklist and standards - tags: [review, quality, process] - mcp_servers: - name: filesystem file: mcp/filesystem.json description: Project filesystem access credentials: - name: ALLOWED_DIRECTORIES - description: Directories to expose to the AI + description: Directories to expose required: false default: "." - name: github file: mcp/github.json - description: GitHub API access for PRs and issues + description: GitHub API access credentials: - name: GITHUB_TOKEN - description: GitHub personal access token with repo scope + description: GitHub personal access token required: true - example: "ghp_xxxxxxxxxxxxxxxxxxxx" hooks: - name: pre-commit @@ -411,286 +235,40 @@ components: description: Run frontend and backend test suites command_type: shell - - name: dev - file: commands/dev.sh - description: Start development servers (frontend + backend) - command_type: shell - resources: - name: editorconfig file: resources/.editorconfig - description: Editor configuration for consistent formatting + description: Editor configuration install_path: .editorconfig checksum: sha256:d4e5f6a7b8c9... size: 280 - - - name: prettierrc - file: resources/.prettierrc - description: Prettier configuration for frontend code - install_path: .prettierrc - checksum: sha256:e5f6a7b8c9d0... - size: 120 -``` - -### Key Files - -**`mcp/github.json`** - -```json -{ - "mcpServers": { - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "env": { - "GITHUB_TOKEN": "${GITHUB_TOKEN}" - } - } - } -} -``` - -**`commands/test-all.sh`** - -```bash -#!/usr/bin/env bash -set -e - -echo "=== Backend Tests ===" -cd backend -if command -v pytest &> /dev/null; then - pytest --cov=. --cov-report=term -q -else - echo "pytest not found, skipping backend tests" -fi -cd .. - -echo "" -echo "=== Frontend Tests ===" -cd frontend -if [ -f package.json ]; then - npm test -- --coverage --watchAll=false -else - echo "No package.json found, skipping frontend tests" -fi -cd .. - -echo "" -echo "All test suites complete." ``` ### Installation ```bash -# Claude Code: gets all 11 components -devsync package install ./fullstack-team --ide claude - -# Cursor: gets 4 instructions + 2 MCP servers + 2 resources = 8 components -devsync package install ./fullstack-team --ide cursor +# v1 packages use file-copy mode automatically +devsync install ./fullstack-team -# Copilot: gets 4 instructions + 2 MCP servers = 6 components -devsync package install ./fullstack-team --ide copilot +# Install to specific tools +devsync install ./fullstack-team --tool claude --tool cursor ``` --- -## 4. Claude Code Power User - -A Claude-specific package that uses skills, memory files, commands, and hooks to build a highly customized Claude Code environment. +## 4. Upgrading v1 to v2 -### Directory Structure - -``` -claude-power-user/ -├── ai-config-kit-package.yaml -├── skills/ -│ ├── review-pr/ -│ │ └── SKILL.md -│ └── create-test/ -│ └── SKILL.md -├── memory_files/ -│ ├── CLAUDE.md -│ └── src/ -│ └── api/ -│ └── CLAUDE.md -├── commands/ -│ ├── deploy.sh -│ └── db-migrate.sh -└── hooks/ - ├── pre-commit.sh - └── notification.sh -``` - -### Manifest - -```yaml -name: claude-power-user -version: 1.0.0 -description: Advanced Claude Code setup with skills, memory files, and automation -author: Senior Dev -license: MIT -namespace: personal/claude-config - -components: - skills: - - name: review-pr - file: skills/review-pr - description: Automated PR review with security and performance checks - - - name: create-test - file: skills/create-test - description: Generate unit tests for a given function or module - - memory_files: - - name: project-context - file: memory_files/CLAUDE.md - description: Project architecture, conventions, and active decisions - - - name: api-context - file: memory_files/src/api/CLAUDE.md - description: API layer patterns and endpoint conventions - - commands: - - name: deploy - file: commands/deploy.sh - description: Deploy to staging or production - command_type: shell - - - name: db-migrate - file: commands/db-migrate.sh - description: Run database migrations with safety checks - command_type: shell - - hooks: - - name: pre-commit - file: hooks/pre-commit.sh - description: Comprehensive pre-commit checks - hook_type: pre-commit - - - name: notification - file: hooks/notification.sh - description: Send Slack notification on task completion - hook_type: post-task -``` - -### Key Files - -**`skills/review-pr/SKILL.md`** - -```markdown ---- -name: review-pr -description: Review a pull request for correctness, security, and performance ---- - -# PR Review Skill - -Review the specified pull request thoroughly. - -## Process - -1. Fetch the PR diff using the GitHub CLI -2. Check each changed file for: - - Correctness: logic errors, edge cases, null handling - - Security: injection risks, auth bypasses, hardcoded secrets - - Performance: N+1 queries, unnecessary allocations, missing indexes - - Style: naming conventions, code organization, documentation -3. Identify any missing test coverage for changed code -4. Generate a structured review comment with findings organized by severity - -## Output Format - -Produce a review with sections: -- **Critical** -- must fix before merge -- **Suggestions** -- improvements to consider -- **Positive** -- things done well -``` - -**`skills/create-test/SKILL.md`** - -```markdown ---- -name: create-test -description: Generate comprehensive unit tests for a function or module ---- - -# Create Test Skill - -Generate unit tests for the specified function, class, or module. - -## Process - -1. Read the source file and understand the function signature and behavior -2. Identify edge cases: empty inputs, boundary values, error conditions -3. Generate tests using the project's test framework (pytest by default) -4. Follow the Arrange-Act-Assert pattern -5. Use fixtures and parametrize for related test cases -6. Include both success and failure scenarios - -## Requirements - -- Tests must be independent and not rely on execution order -- Mock external dependencies (database, network, filesystem) -- Assert specific values, not just truthiness -- Name tests descriptively: test___ -``` - -**`memory_files/CLAUDE.md`** - -```markdown -# Project Context - -## Architecture - -Monorepo with three services: -- `src/api/` -- FastAPI HTTP service (port 8000) -- `src/worker/` -- Celery task workers -- `src/shared/` -- Shared models and utilities - -## Key Decisions - -- SQLAlchemy 2.0 with async sessions -- Pydantic v2 for validation -- pytest with factory_boy for test data -- Alembic for migrations (always review generated SQL) - -## Development Flow - -1. Create feature branch from main -2. Write tests first (TDD) -3. Implement the feature -4. Run `invoke quality` before committing -5. Open PR and request review -``` - -**`memory_files/src/api/CLAUDE.md`** - -```markdown -# API Layer Context - -## Route Organization - -Routes are organized by resource in `src/api/routes/`: -- `users.py` -- user CRUD and authentication -- `projects.py` -- project management -- `tasks.py` -- task operations - -## Patterns - -- All endpoints use dependency injection for database sessions -- Authentication via `get_current_user` dependency -- Response models are separate from database models -- Use `status_code` parameter on route decorators, not manual responses -``` - -### Installation +Convert any v1 package to v2 format using `--upgrade`: ```bash -# Only Claude Code supports all these component types -devsync package install ./claude-power-user --ide claude +# Upgrade v1 package +devsync extract --upgrade ./fullstack-team --output ./fullstack-v2 --name fullstack-team + +# The new package uses practice declarations +cat fullstack-v2/devsync-package.yaml ``` -For other IDEs, only the supported components are installed. Since this package has no instructions or resources, installing to Cursor or Windsurf results in no components installed. +The upgraded package preserves MCP server configurations and converts instructions into practice declarations. !!! tip "Mixed packages" - If you want a package that works across IDEs, include instructions alongside Claude-specific components. Other IDEs will install the instructions and skip the rest. + A v2 package can include both practice declarations and v1-style components. DevSync handles both formats during installation. diff --git a/docs/packages/index.md b/docs/packages/index.md index 366290e..940684a 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -2,82 +2,72 @@ ## What Are Packages? -A configuration package is a directory containing multiple related components that configure AI coding assistants as a unit. Instead of installing instructions, MCP servers, hooks, and commands individually, a package bundles them together with a manifest that declares what is included and how components relate to each other. +A configuration package is a shareable bundle of coding practices, MCP server configurations, and other components that configure AI coding assistants as a unit. DevSync v2 packages use AI to extract abstract practice declarations and adapt them intelligently when installed. -A package can contain any combination of these component types: +A package can contain: | Component Type | Description | |----------------|-------------| -| **Instructions** | Markdown guidelines that shape AI behavior | +| **Practices** | Abstract declarations of coding standards (AI-native, v2) | +| **Instructions** | Markdown guidelines that shape AI behavior (v1 compat) | | **MCP Servers** | Model Context Protocol server configurations | | **Hooks** | Scripts triggered by IDE lifecycle events | | **Commands** | Reusable slash commands and shell scripts | -| **Skills** | Claude Code skill directories (SKILL.md format) | -| **Workflows** | Windsurf multi-step automated processes | -| **Memory Files** | CLAUDE.md persistent context files | | **Resources** | Configuration files, templates, .gitignore, etc. | -## Why Packages Instead of Individual Instructions? +## v2 Packages: AI-Native -Individual instructions work well for standalone guidelines. Packages solve a different problem: coordinating multiple components that work together. +DevSync v2 introduces **practice declarations** -- abstract representations of coding standards that capture intent rather than raw file content. This enables AI-powered adaptation when installing into new projects. -**Without packages:** +**Creating a package:** ```bash -# Install instructions one at a time -devsync install python-style-guide -devsync install testing-strategy - -# Manually configure MCP servers -# Manually create hooks -# Manually set up commands -# Hope the pieces work together +# Extract practices from your project +devsync extract --output ./team-standards --name team-standards ``` -**With packages:** +**Installing a package:** ```bash -devsync package install ./python-dev-setup --ide claude +# AI adapts practices to the target project's existing setup +devsync install ./team-standards ``` -One command installs all components, translates them to the correct IDE format, and tracks everything for later management. +The AI reads the target project's existing rules and merges incoming practices intelligently -- no duplication, no blind overwriting. -Packages provide: +## v1 Backward Compatibility -- **Atomicity** -- install or uninstall an entire configuration as one unit -- **IDE adaptation** -- components are automatically filtered and translated per IDE -- **Tracking** -- installed packages are recorded in `.devsync/packages.json` -- **Conflict handling** -- skip, overwrite, or rename files that already exist -- **Reproducibility** -- share a package directory and anyone gets the same setup - -## Installation Workflow - -When you run `devsync package install`, the system executes these steps in order: +DevSync v2 fully supports v1 packages using `ai-config-kit-package.yaml` manifests. When a v1 package is detected, installation uses file-copy mode automatically. You can upgrade v1 packages to v2 format: +```bash +devsync extract --upgrade ./old-v1-package --output ./v2-package --name my-package ``` -1. Parse Manifest - Read ai-config-kit-package.yaml, validate required fields, - resolve component file references -2. Check Existing Installation - Query .devsync/packages.json to determine if this package - is already installed (enables reinstall detection) +## Package Structure -3. Filter by IDE Capability - Remove components the target IDE does not support - (e.g., hooks are skipped for Cursor) +### v2 Package (AI-native) -4. Translate Components - Convert each component to the IDE-specific format - (file extension, directory path, content structure) +``` +team-standards/ +├── devsync-package.yaml # v2 manifest with practices +├── practices/ # Practice declaration files +│ ├── type-safety.md +│ └── error-handling.md +├── mcp/ # MCP server configs +│ └── github.json +└── README.md +``` -5. Install Files - Write files to the project, applying the chosen - conflict resolution strategy (skip/overwrite/rename) +### v1 Package (file-copy) -6. Track Installation - Record the package name, version, component paths, - checksums, and timestamps in .devsync/packages.json +``` +my-package/ +├── ai-config-kit-package.yaml # v1 manifest +├── instructions/ # Instruction .md files +├── mcp/ # MCP server .json configs +├── hooks/ # Hook shell scripts +├── commands/ # Command scripts +└── resources/ # Any additional files ``` ## IDE Compatibility @@ -86,18 +76,12 @@ Different AI coding tools support different component types. When installing a p | Component | Claude Code | Cursor | Windsurf | Copilot | Kiro | Cline | Roo Code | Codex CLI | |-----------|:-----------:|:------:|:--------:|:-------:|:----:|:-----:|:--------:|:---------:| -| Instructions | Y | Y | Y | Y | Y | Y | Y | Y | +| Practices/Instructions | Y | Y | Y | Y | Y | Y | Y | Y | | MCP Servers | Y | Y | Y | Y | -- | -- | Y | -- | | Hooks | Y | -- | -- | -- | -- | -- | -- | -- | | Commands | Y | -- | -- | -- | -- | -- | Y | -- | -| Skills | Y | -- | -- | -- | -- | -- | -- | -- | -| Workflows | -- | -- | Y | -- | -- | -- | -- | -- | -| Memory Files | Y | -- | -- | -- | -- | -- | -- | -- | | Resources | Y | Y | Y | -- | Y | Y | Y | Y | -!!! info "Additional IDEs" - DevSync also supports Gemini CLI, Antigravity, Amazon Q, JetBrains AI, Junie, Zed, Continue.dev, Aider, Trae, Augment, Tabnine, OpenHands, Amp, and OpenCode. All support instructions and resources. MCP support varies -- check `devsync/ai_tools/capability_registry.py` for the full matrix. - ### Translation Paths Components are installed to IDE-specific locations: @@ -105,19 +89,17 @@ Components are installed to IDE-specific locations: === "Claude Code" ``` - Instructions -> .claude/rules/*.md + Practices -> .claude/rules/*.md MCP Servers -> .claude/settings.local.json Hooks -> .claude/hooks/*.sh Commands -> .claude/commands/*.sh - Skills -> .claude/skills//SKILL.md - Memory Files -> CLAUDE.md (project root or subdirectories) Resources -> specified install_path ``` === "Cursor" ``` - Instructions -> .cursor/rules/*.mdc + Practices -> .cursor/rules/*.mdc MCP Servers -> .cursor/mcp.json Resources -> specified install_path ``` @@ -125,49 +107,30 @@ Components are installed to IDE-specific locations: === "Windsurf" ``` - Instructions -> .windsurf/rules/*.md + Practices -> .windsurf/rules/*.md MCP Servers -> ~/.codeium/windsurf/mcp_config.json - Workflows -> .windsurf/workflows/*.md Resources -> specified install_path ``` === "GitHub Copilot" ``` - Instructions -> .github/instructions/*.instructions.md + Practices -> .github/instructions/*.instructions.md MCP Servers -> .vscode/mcp.json ``` === "Roo Code" ``` - Instructions -> .roo/rules/*.md + Practices -> .roo/rules/*.md MCP Servers -> .roo/mcp.json Commands -> .roo/commands/*.md Resources -> specified install_path ``` -## Package Structure - -Every package is a directory with an `ai-config-kit-package.yaml` manifest and one or more component directories: - -``` -my-package/ -├── ai-config-kit-package.yaml # Required manifest -├── README.md # Recommended documentation -├── instructions/ # Instruction .md files -├── mcp/ # MCP server .json configs -├── hooks/ # Hook shell scripts -├── commands/ # Command shell scripts -├── skills/ # Skill directories (SKILL.md) -├── workflows/ # Workflow files -├── memory_files/ # CLAUDE.md files -└── resources/ # Any additional files -``` - ## Next Steps -- [Creating Packages](creating.md) -- build your own package from scratch or from an existing project +- [Creating Packages](creating.md) -- extract practices from your project - [Component Types](components.md) -- detailed reference for each component type -- [Installing Packages](installing.md) -- install, list, and uninstall packages +- [Installing Packages](installing.md) -- install, list, and manage packages - [Examples](examples.md) -- complete real-world package examples diff --git a/docs/packages/installing.md b/docs/packages/installing.md index a9b2995..4520b53 100644 --- a/docs/packages/installing.md +++ b/docs/packages/installing.md @@ -3,354 +3,218 @@ ## Basic Installation ```bash -devsync package install --ide +devsync install ``` -The `` is the directory containing `ai-config-kit-package.yaml`. The `--ide` flag specifies which AI coding tool to target. +The `` can be a local directory path or a Git URL. ```bash -# From the project directory -cd ~/my-project -devsync package install ./python-dev-setup --ide claude +# Local directory +devsync install ./team-standards # Absolute path -devsync package install /home/user/packages/python-dev-setup --ide claude +devsync install /home/user/packages/team-standards -# Parent directory -devsync package install ../shared-packages/security --ide cursor +# Git URL +devsync install https://github.com/company/team-standards ``` ## Command Options ```bash -devsync package install \ - --ide \ - [--project ] \ +devsync install \ + [--tool ] \ + [--no-ai] \ [--conflict ] \ - [--force] \ - [--quiet] \ - [--json] + [--project-dir ] ``` -### `--ide, -i` (required) +### `--tool, -t` (optional, repeatable) -Target IDE. This determines which components are installed and where files are placed. +Target specific AI tools. If omitted, DevSync auto-detects all installed tools. -=== "Claude Code" - - ```bash - devsync package install ./pkg --ide claude - ``` - - Installs all component types. Files go to `.claude/rules/`, `.claude/hooks/`, `.claude/commands/`, `.claude/skills/`, and `CLAUDE.md`. - -=== "Cursor" - - ```bash - devsync package install ./pkg --ide cursor - ``` - - Installs instructions (as `.mdc` files), MCP servers, and resources. Hooks, commands, skills, workflows, and memory files are skipped. - -=== "Windsurf" - - ```bash - devsync package install ./pkg --ide windsurf - ``` - - Installs instructions, MCP servers, workflows, and resources. Files go to `.windsurf/rules/` and `.windsurf/workflows/`. - -=== "GitHub Copilot" - - ```bash - devsync package install ./pkg --ide copilot - ``` - - Installs instructions (as `.instructions.md` files) and MCP servers. Files go to `.github/instructions/` and `.vscode/mcp.json`. - -=== "Roo Code" - - ```bash - devsync package install ./pkg --ide roo - ``` - - Installs instructions, MCP servers, commands, and resources. Files go to `.roo/rules/`, `.roo/mcp.json`, and `.roo/commands/`. - -=== "Other IDEs" - - ```bash - devsync package install ./pkg --ide kiro - devsync package install ./pkg --ide cline - devsync package install ./pkg --ide codex - devsync package install ./pkg --ide gemini - # ... and more - ``` +```bash +# Install to Claude Code only +devsync install ./pkg --tool claude - Each IDE receives only the component types it supports. Use `devsync/ai_tools/capability_registry.py` as the definitive reference. +# Install to Claude Code and Cursor +devsync install ./pkg --tool claude --tool cursor +``` -### `--project, -p` (optional) +### `--no-ai` (optional) -Override the project root directory. By default, DevSync detects the project root by searching for `.git/`, `pyproject.toml`, `package.json`, or similar markers. +Skip AI adaptation and copy files directly: ```bash -# Install to a different project -devsync package install ./pkg --ide claude --project ~/other-project - -# Install same package to multiple projects -devsync package install ./pkg --ide claude --project ~/project-a -devsync package install ./pkg --ide claude --project ~/project-b +devsync install ./pkg --no-ai ``` +Useful when you don't have an LLM configured or want exact file copies. + ### `--conflict, -c` (optional) -Conflict resolution strategy when files already exist. Default: `skip`. +Conflict resolution strategy when files already exist. Default: `prompt`. ```bash ---conflict skip # Keep existing files, do not install conflicting components ---conflict overwrite # Replace existing files with package versions ---conflict rename # Install with a numbered suffix (e.g., style-guide-1.md) +--conflict prompt # Ask what to do (default) +--conflict skip # Keep existing files +--conflict overwrite # Replace existing files +--conflict rename # Install with numbered suffix ``` See [Conflict Resolution](#conflict-resolution) below for detailed behavior. -### `--force, -f` (optional) +### `--project-dir, -p` (optional) -Force reinstallation even if the package is already tracked in `.devsync/packages.json`. +Override the target project directory: ```bash -devsync package install ./pkg --ide claude --force +devsync install ./pkg --project-dir ~/other-project ``` -Without `--force`, reinstalling an already-installed package still proceeds but records the operation as a reinstall. Use `--force` combined with `--conflict overwrite` for a clean reset. - -### `--quiet, -q` (optional) +## AI Adaptation -Suppress informational output. Only errors and the final result are printed. +With AI enabled (default), DevSync intelligently merges practices with existing rules: ```bash -devsync package install ./pkg --ide claude --quiet -``` +$ devsync install ./team-standards -### `--json` (optional) +Installing team-standards... -Output results as JSON for scripting and CI/CD integration. + Detected tools: Claude Code, Cursor -```bash -devsync package install ./pkg --ide claude --json -``` + Claude Code: + Created: .claude/rules/type-safety.md + Merged: .claude/rules/code-style.md (adapted to existing) + Created: .claude/rules/testing.md + + Cursor: + Created: .cursor/rules/type-safety.mdc + Merged: .cursor/rules/code-style.mdc (adapted to existing) + Created: .cursor/rules/testing.mdc -```json -{ - "success": true, - "status": "complete", - "package_name": "python-dev-setup", - "version": "2.0.0", - "installed_count": 6, - "skipped_count": 0, - "failed_count": 0, - "components_installed": { - "instruction": 2, - "mcp_server": 1, - "hook": 1, - "command": 2 - }, - "is_reinstall": false, - "error_message": null -} + MCP: Configured 1 server (1 credential prompted) + +Installation complete. ``` +The AI reads existing rules in the target project and: + +- **Creates** new files for practices that don't exist +- **Merges** overlapping practices into existing files, avoiding duplication +- **Adapts** content to match the project's conventions + ## Conflict Resolution -When a package component targets a file that already exists in the project, the conflict strategy determines behavior. +When an installed component targets a file that already exists: -### Skip (Default) +### Prompt (Default) -Existing files are preserved. The conflicting component is not installed. +Asks what to do for each conflict. -```bash -devsync package install ./pkg --ide claude --conflict skip -``` +### Skip -``` -Installed: 4 -Skipped: 1 <- existing file kept -Failed: 0 -``` +Existing files are preserved. Conflicting components are not installed. -Use `skip` when you have local customizations you want to preserve. +```bash +devsync install ./pkg --conflict skip +``` ### Overwrite Existing files are replaced with the package version. ```bash -devsync package install ./pkg --ide claude --conflict overwrite -``` - -``` -Installed: 5 <- all files written, including replacements -Skipped: 0 -Failed: 0 +devsync install ./pkg --conflict overwrite ``` !!! warning - Overwrite permanently replaces local changes. There is no undo. Consider committing your changes to version control before using this option. + Overwrite permanently replaces local changes. Consider committing your changes to version control first. ### Rename Both versions are kept. The new file receives a numbered suffix. ```bash -devsync package install ./pkg --ide claude --conflict rename +devsync install ./pkg --conflict rename ``` -After installation: - ``` -.claude/rules/code-quality.md <- original, untouched -.claude/rules/code-quality-1.md <- from package +.claude/rules/code-quality.md # original, untouched +.claude/rules/code-quality-1.md # from package ``` -Subsequent installs with `rename` increment the suffix: +## MCP Credential Prompting + +If a package includes MCP servers that require credentials, DevSync prompts during installation: ``` -.claude/rules/code-quality-2.md -.claude/rules/code-quality-3.md -``` +MCP server "github" requires credentials: -### Comparison + GITHUB_TOKEN (required): GitHub personal access token + > [enter value] -| Strategy | Existing File | Package File | Result | -|----------|--------------|--------------|--------| -| `skip` | Preserved | Not installed | Original unchanged | -| `overwrite` | Replaced | Installed | Package version wins | -| `rename` | Preserved | Installed with suffix | Both versions exist | + ALLOWED_DIRECTORIES (optional, default: "."): Directories to expose + > [enter value or press Enter for default] +``` -## IDE Filtering in Practice +Credentials are set as environment variables -- never written to tracked files. -A package with 7 components (2 instructions, 1 MCP server, 1 hook, 1 command, 1 workflow, 1 resource) installs differently per IDE: +## IDE Filtering -=== "Claude Code" +Packages install different components per IDE based on capability: - ```bash - $ devsync package install ./pkg --ide claude +=== "Claude Code" - Successfully installed pkg v1.0.0 - Installed: 6 # instructions, MCP, hook, command, resource - Skipped: 1 # workflow (Windsurf-only) - ``` + Gets all component types: practices, MCP servers, hooks, commands, resources. === "Cursor" - ```bash - $ devsync package install ./pkg --ide cursor - - Partially installed pkg v1.0.0 - Installed: 4 # instructions, MCP, resource - Skipped: 3 # hook, command, workflow - ``` + Gets practices (as `.mdc` files), MCP servers, and resources. Hooks and commands are skipped. === "Windsurf" - ```bash - $ devsync package install ./pkg --ide windsurf + Gets practices, MCP servers, and resources. Hooks and commands are skipped. - Partially installed pkg v1.0.0 - Installed: 5 # instructions, MCP, workflow, resource - Skipped: 2 # hook, command - ``` +=== "Other IDEs" -The installation status is `complete` when all package components are installed, and `partial` when some are filtered by IDE capability. + Each IDE receives only the component types it supports. Run `devsync tools` to check support. ## Listing Installed Packages ```bash -devsync package list +$ devsync list ``` ``` Installed packages in /home/user/my-project: -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ -┃ Package ┃ Version ┃ Status ┃ Components ┃ Installed ┃ -┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ -│ acme/python-dev-setup │ 2.0.0 │ complete │ 6 │ 2025-01-14 10:30│ -│ security/compliance-pack │ 1.1.0 │ partial │ 3 │ 2025-01-15 09:00│ -└────────────────────────────────┴─────────┴────────────┴────────────┴─────────────────┘ + team-standards v1.0.0 4 practices, 1 MCP server Claude Code, Cursor + security-rules v2.1.0 3 practices Claude Code Total: 2 package(s) ``` -### List for a specific project - -```bash -devsync package list --project ~/other-project -``` - ### JSON output ```bash -devsync package list --json -``` - -```json -[ - { - "name": "python-dev-setup", - "namespace": "acme/python-packages", - "version": "2.0.0", - "status": "complete", - "scope": "project", - "installed_at": "2025-01-14T10:30:00", - "updated_at": "2025-01-14T10:30:00", - "component_count": 6 - } -] +devsync list --json ``` ## Uninstalling Packages ```bash -devsync package uninstall +devsync uninstall team-standards ``` -This removes all component files installed by the package and deletes the tracking record from `.devsync/packages.json`. - -### Interactive (default) +This removes all files installed by the package and deletes the tracking record from `.devsync/packages.json`. ```bash -$ devsync package uninstall python-dev-setup - -Package to uninstall: - Name: python-dev-setup - Version: 2.0.0 - Components: 6 - -Are you sure you want to uninstall this package? [y/N]: y +# Skip confirmation +devsync uninstall team-standards --force - Removed: .claude/rules/python-style.md - Removed: .claude/rules/testing-strategy.md - Removed: .claude/hooks/pre-commit.sh - Removed: .claude/commands/test.sh - Removed: .claude/commands/lint.sh - Removed: .gitignore - -Uninstalled python-dev-setup v2.0.0 - Removed 6 file(s) -``` - -### Non-interactive - -Skip the confirmation prompt: - -```bash -devsync package uninstall python-dev-setup --yes -``` - -### Specifying project - -```bash -devsync package uninstall python-dev-setup --project ~/other-project +# Uninstall from specific tool only +devsync uninstall team-standards --tool cursor ``` ## Troubleshooting @@ -361,16 +225,12 @@ Point to the package directory, not the YAML file: ```bash # Correct -devsync package install ./my-package --ide claude +devsync install ./my-package # Wrong -devsync package install ./my-package/ai-config-kit-package.yaml --ide claude +devsync install ./my-package/devsync-package.yaml ``` -### "Missing required field" - -The manifest must include `name`, `version`, `description`, `author`, `license`, and `namespace`. Check the error message for which field is missing. - ### Components not appearing in IDE 1. Verify the IDE supports the component type (see [IDE Compatibility](index.md#ide-compatibility)) @@ -383,6 +243,8 @@ ls -la .cursor/rules/ # Cursor ls -la .windsurf/rules/ # Windsurf ``` -### Package shows "partial" status +### AI adaptation not working -This is expected when the target IDE does not support all component types in the package. The `partial` status indicates that some components were filtered out by IDE capability, not that the installation failed. +1. Run `devsync setup` to configure your LLM provider +2. Ensure your API key environment variable is set +3. Use `--no-ai` as a fallback for file-copy mode diff --git a/docs/reference/cli-reference.md b/docs/reference/cli-reference.md index 87dfc41..220be01 100644 --- a/docs/reference/cli-reference.md +++ b/docs/reference/cli-reference.md @@ -2,156 +2,35 @@ Complete reference for all `devsync` commands. Install with `pip install devsync`. -## Top-Level Commands +## `devsync setup` -### `devsync install` - -Install instructions from your library or directly from a source. +Configure your LLM provider for AI-powered extraction and installation. ``` -devsync install [NAMES...] [OPTIONS] +devsync setup ``` -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--from` | `-f` | `TEXT` | -- | Source URL or path for direct install (bypasses library) | -| `--tool` | `-t` | `TEXT` | -- | AI tool(s) to install to. Repeatable. Values: `cursor`, `copilot`, `windsurf`, `claude`, `kiro`, `cline`, `roo`, `codex`, `gemini`, etc. | -| `--conflict` | `-c` | `TEXT` | `prompt` | Conflict resolution strategy: `prompt`, `skip`, `rename`, `overwrite` | -| `--bundle` | `-b` | flag | `False` | Install as bundle (multiple instructions) | - -When called without arguments, launches the interactive TUI for browsing and selecting instructions. - -```bash -# Interactive TUI -devsync install - -# Install specific instruction from library -devsync install python-style - -# Install from specific source (namespace/name) -devsync install company/python-style - -# Install multiple instructions -devsync install python-style testing-guide api-design +Interactive wizard that: -# Install to specific tools -devsync install python-style --tool cursor --tool windsurf +1. Prompts for provider selection (Anthropic, OpenAI, OpenRouter) +2. Auto-detects API keys from environment variables +3. Optionally overrides the default model +4. Saves configuration to `~/.devsync/config.yaml` -# Direct install from source URL -devsync install python-style --from https://github.com/company/instructions +Default models per provider: -# Install bundle directly -devsync install python-backend --bundle --from https://github.com/company/instructions -``` - ---- - -### `devsync download` - -Download instructions from a source into your local library. - -``` -devsync download [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--from` | `-f` | `TEXT` | *required* | Source URL or local directory path | -| `--ref` | `-r` | `TEXT` | -- | Git reference (tag, branch, or commit) to download | -| `--as` | `-a` | `TEXT` | -- | Friendly alias for this source (auto-generated if omitted) | -| `--force` | -- | flag | `False` | Re-download even if already in library | - -```bash -# Download from GitHub -devsync download --from github.com/company/instructions - -# Download specific version -devsync download --from github.com/company/instructions --ref v1.0.0 - -# Download with custom alias -devsync download --from github.com/company/instructions --as company - -# Download from local folder -devsync download --from ./my-instructions --as local - -# Force re-download -devsync download --from github.com/company/instructions --force -``` - ---- - -### `devsync update` - -Update downloaded instructions to their latest versions. - -``` -devsync update [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--namespace` | `-n` | `TEXT` | -- | Repository namespace to update | -| `--all` | `-a` | flag | `False` | Update all repositories in library | - -```bash -devsync update --namespace github.com_company_instructions -devsync update --all -``` - ---- - -### `devsync delete` - -Delete a source from your local library. - -``` -devsync delete NAMESPACE [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `NAMESPACE` | `TEXT` | Repository namespace to delete | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--force` | `-f` | flag | `False` | Skip confirmation prompt | +| Provider | Default Model | +|----------|--------------| +| Anthropic | `claude-sonnet-4-20250514` | +| OpenAI | `gpt-4o` | +| OpenRouter | `anthropic/claude-sonnet-4-20250514` | !!! note - This removes the downloaded source from your library but does **not** uninstall instructions from your AI tools. Use `devsync uninstall` for that. - -```bash -devsync delete github.com_company_instructions -devsync delete github.com_company_instructions --force -``` + API keys are never written to disk. Only the provider name and model are saved. Keys are read from environment variables at runtime. --- -### `devsync uninstall` - -Uninstall an instruction from your AI tools. - -``` -devsync uninstall NAME [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `NAME` | `TEXT` | Instruction name to uninstall | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--tool` | `-t` | `TEXT` | -- | Uninstall from specific AI tool only | -| `--force` | `-f` | flag | `False` | Skip confirmation prompt | - -```bash -devsync uninstall python-best-practices -devsync uninstall python-best-practices --tool cursor -devsync uninstall python-best-practices --force -``` - ---- - -### `devsync tools` +## `devsync tools` Show detected AI coding tools installed on your system. @@ -163,340 +42,103 @@ No options. Displays a table of detected tools and their configuration directori --- -### `devsync version` - -Show the installed DevSync version. - -``` -devsync version -``` - ---- - -## List Commands - -### `devsync list available` - -List available instructions from a source without downloading. - -``` -devsync list available [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--from` | `-f` | `TEXT` | *required* | Source URL or local directory path | -| `--tag` | `-t` | `TEXT` | -- | Filter by tag | -| `--bundles-only` | -- | flag | `False` | Show only bundles | -| `--instructions-only` | -- | flag | `False` | Show only instructions | - -```bash -devsync list available --from github.com/company/instructions -devsync list available --from github.com/company/instructions --tag python -devsync list available --from github.com/company/instructions --bundles-only -``` - ---- - -### `devsync list installed` - -List instructions installed in your AI tools. - -``` -devsync list installed [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--tool` | `-t` | `TEXT` | -- | Filter by AI tool | -| `--source` | `-s` | `TEXT` | -- | Filter by source alias or name | - -```bash -devsync list installed -devsync list installed --tool cursor -devsync list installed --source company -``` - ---- - -### `devsync list library` +## `devsync extract` -List sources and instructions in your local library. +Extract practices from a project into a shareable package. ``` -devsync list library [OPTIONS] +devsync extract [OPTIONS] ``` | Option | Short | Type | Default | Description | |--------|-------|------|---------|-------------| -| `--source` | `-s` | `TEXT` | -- | Filter by source alias | -| `--instructions` | `-i` | flag | `False` | Show individual instructions instead of repositories | +| `--output` | `-o` | `PATH` | `./devsync-package` | Output directory for the package | +| `--name` | `-n` | `TEXT` | -- | Package name | +| `--no-ai` | -- | flag | `False` | Use file-copy mode instead of AI extraction | +| `--project-dir` | `-p` | `PATH` | `.` | Project directory to extract from | +| `--upgrade` | `-u` | `PATH` | -- | Path to v1 package to upgrade to v2 format | ```bash -devsync list library -devsync list library --instructions -devsync list library --source company -``` - ---- +# AI-powered extraction +devsync extract --output ./team-standards --name team-standards -## Template Commands +# File-copy mode (no LLM needed) +devsync extract --output ./team-standards --name team-standards --no-ai -### `devsync template init` +# Extract from a different directory +devsync extract --project-dir ~/other-project --output ./pkg --name other-pkg -Create a new template repository with scaffolded structure. - -``` -devsync template init NAME +# Upgrade a v1 package to v2 format +devsync extract --upgrade ./old-v1-package --output ./v2-package --name my-package ``` -| Argument | Type | Description | -|----------|------|-------------| -| `NAME` | `TEXT` | Name for the new template repository | - --- -### `devsync template install` +## `devsync install` -Install templates from a source. +Install a package into the current project with AI adaptation. ``` -devsync template install SOURCE [OPTIONS] +devsync install SOURCE [OPTIONS] ``` | Argument | Type | Description | |----------|------|-------------| -| `SOURCE` | `TEXT` | Source URL or local path | +| `SOURCE` | `TEXT` | Package source: local path, directory, or Git URL | | Option | Short | Type | Default | Description | |--------|-------|------|---------|-------------| -| `--as` | `-a` | `TEXT` | -- | Custom namespace alias | -| `--scope` | `-s` | `TEXT` | `project` | Installation scope: `project` or `global` | -| `--conflict` | `-c` | `TEXT` | `prompt` | Conflict resolution: `prompt`, `skip`, `overwrite`, `rename` | +| `--tool` | `-t` | `TEXT` | -- | Target AI tool(s). Repeatable. Auto-detects if not specified | +| `--no-ai` | -- | flag | `False` | Use file-copy mode instead of AI adaptation | +| `--conflict` | `-c` | `TEXT` | `prompt` | Conflict strategy: `prompt`, `skip`, `rename`, `overwrite` | +| `--project-dir` | `-p` | `PATH` | `.` | Target project directory | ```bash -devsync template install https://github.com/company/templates -devsync template install ./local-templates --as company -devsync template install https://github.com/company/templates --conflict overwrite -``` +# Install from local directory +devsync install ./team-standards ---- +# Install from Git +devsync install https://github.com/company/standards -### `devsync template list` +# Install to specific tools only +devsync install ./pkg --tool claude --tool cursor -List available templates. +# File-copy mode (no AI adaptation) +devsync install ./pkg --no-ai -``` -devsync template list [OPTIONS] +# Auto-overwrite conflicts +devsync install ./pkg --conflict overwrite ``` -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--scope` | `-s` | `TEXT` | `project` | Scope to list: `project` or `global` | - --- -### `devsync template update` +## `devsync list` -Update installed templates to their latest versions. +List installed packages in the current project. ``` -devsync template update NAMESPACE [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `NAMESPACE` | `TEXT` | Template namespace to update | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--all` | `-a` | flag | `False` | Update all installed templates | - ---- - -### `devsync template uninstall` - -Remove installed templates. - -``` -devsync template uninstall NAMESPACE [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `NAMESPACE` | `TEXT` | Template namespace to uninstall | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--force` | `-f` | flag | `False` | Skip confirmation prompt | - ---- - -### `devsync template validate` - -Validate installed templates for integrity issues. - -``` -devsync template validate [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--scope` | `-s` | `TEXT` | `project` | Scope to validate: `project` or `global` | -| `--fix` | -- | flag | `False` | Auto-fix detected issues | -| `--verbose` | `-v` | flag | `False` | Show detailed diagnostic output | - ---- - -### `devsync template backup list` - -List available template backups. - -``` -devsync template backup list [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--scope` | `-s` | `TEXT` | `project` | Scope: `project` or `global` | - ---- - -### `devsync template backup restore` - -Restore files from a backup. - -``` -devsync template backup restore [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--scope` | `-s` | `TEXT` | `project` | Scope: `project` or `global` | - ---- - -### `devsync template backup cleanup` - -Remove old backups. - -``` -devsync template backup cleanup [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--days` | `-d` | `INT` | `30` | Remove backups older than this many days | -| `--scope` | `-s` | `TEXT` | `project` | Scope: `project` or `global` | - ---- - -## MCP Commands - -### `devsync mcp install` - -Install MCP server definitions from a template repository. - -``` -devsync mcp install SOURCE [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `SOURCE` | `TEXT` | Source URL or local path containing MCP definitions | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--as` | `-a` | `TEXT` | -- | Custom namespace alias | - ---- - -### `devsync mcp configure` - -Configure credentials for installed MCP servers. - -``` -devsync mcp configure NAMESPACE -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `NAMESPACE` | `TEXT` | MCP template namespace to configure | - ---- - -### `devsync mcp sync` - -Sync MCP server configurations to AI tools. - -``` -devsync mcp sync [OPTIONS] -``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--tool` | `-t` | `TEXT` | -- | Specific AI tool to sync to | - ---- - -## Package Commands - -### `devsync package install` - -Install a configuration package to a project. - -``` -devsync package install PATH [OPTIONS] -``` - -| Argument | Type | Description | -|----------|------|-------------| -| `PATH` | `TEXT` | Path to package directory containing `ai-config-kit-package.yaml` | - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--ide` | `-i` | `TEXT` | `claude` | Target IDE: `claude`, `cursor`, `windsurf`, `copilot`, etc. | -| `--project` | `-p` | `TEXT` | -- | Project root directory (defaults to auto-detected) | -| `--conflict` | `-c` | `TEXT` | `skip` | Conflict resolution: `skip`, `overwrite`, `rename` | -| `--force` | `-f` | flag | `False` | Force reinstallation even if already installed | -| `--quiet` | `-q` | flag | `False` | Minimal output | -| `--json` | -- | flag | `False` | Output results as JSON | - -```bash -devsync package install ./python-dev-setup --ide claude -devsync package install ~/packages/my-package --ide cursor --conflict overwrite -devsync package install ./my-package --force --json -``` - ---- - -### `devsync package list` - -List installed packages in a project. - -``` -devsync package list [OPTIONS] +devsync list [OPTIONS] ``` | Option | Short | Type | Default | Description | |--------|-------|------|---------|-------------| -| `--project` | `-p` | `TEXT` | -- | Project root directory (defaults to auto-detected) | +| `--tool` | `-t` | `TEXT` | -- | Filter by AI tool | | `--json` | -- | flag | `False` | Output as JSON | ```bash -devsync package list -devsync package list --json -devsync package list --project ~/my-project +devsync list +devsync list --tool claude +devsync list --json ``` --- -### `devsync package uninstall` +## `devsync uninstall` -Uninstall a package from a project. +Remove an installed package from the current project. ``` -devsync package uninstall NAME [OPTIONS] +devsync uninstall NAME [OPTIONS] ``` | Argument | Type | Description | @@ -505,26 +147,21 @@ devsync package uninstall NAME [OPTIONS] | Option | Short | Type | Default | Description | |--------|-------|------|---------|-------------| -| `--project` | `-p` | `TEXT` | -- | Project root directory (defaults to auto-detected) | -| `--yes` | `-y` | flag | `False` | Skip confirmation prompt | +| `--tool` | `-t` | `TEXT` | -- | Uninstall from specific AI tool only | +| `--force` | `-f` | flag | `False` | Skip confirmation prompt | ```bash -devsync package uninstall test-package -devsync package uninstall my-org/my-package --yes +devsync uninstall team-standards +devsync uninstall team-standards --tool cursor +devsync uninstall team-standards --force ``` --- -### `devsync package create` +## `devsync version` -Create a package from existing project configurations. +Show the installed DevSync version. ``` -devsync package create [OPTIONS] +devsync version ``` - -| Option | Short | Type | Default | Description | -|--------|-------|------|---------|-------------| -| `--name` | `-n` | `TEXT` | -- | Package name | - -Scans the current project for existing configurations (instructions, MCP servers, hooks, commands, skills, workflows, memory files, resources) and generates an `ai-config-kit-package.yaml` manifest. diff --git a/docs/reference/yaml-schemas.md b/docs/reference/yaml-schemas.md index 44d7532..15b605b 100644 --- a/docs/reference/yaml-schemas.md +++ b/docs/reference/yaml-schemas.md @@ -1,10 +1,122 @@ # YAML Schema Reference -DevSync uses three YAML manifest formats for different purposes. +DevSync uses several YAML manifest formats. The primary v2 format is `devsync-package.yaml`. The v1 formats (`ai-config-kit-package.yaml` and `ai-config-kit.yaml`) are still supported for backward compatibility. -## ai-config-kit.yaml +## devsync-package.yaml -The instruction repository manifest. Placed at the root of an instruction repository to define available instructions and bundles. +The v2 package manifest. Placed at the root of a package directory. Supports the `practices` section for AI-native content distribution alongside the v1 `components` section. + +### Schema + +```yaml +# Required fields +name: string # Package identifier (lowercase, alphanumeric, hyphens) +version: string # Semantic version +description: string # Package description + +# Optional metadata +author: string # Package author +license: string # License identifier (e.g., "MIT", "Apache-2.0") +namespace: string # Repository namespace (e.g., "org/repo") + +# Practices (AI-native content -- primary v2 approach) +practices: + - name: string # Practice identifier (lowercase, hyphenated) + intent: string # One-sentence description of what this practice achieves + principles: # Core rules or values (list of strings) + - string + enforcement_patterns: # Concrete implementation examples (list of strings) + - string + examples: # Optional illustrative examples (list of strings) + - string + tags: # Optional categorization tags + - string + +# MCP server configurations (in practices-based packages) +mcp_servers: + - name: string # Server identifier + description: string # What the server provides + command: string # Executable command (e.g., "npx") + args: # Command-line arguments + - string + credentials: # Required environment variables + - name: string # Env var name (UPPER_SNAKE_CASE) + description: string + required: boolean # Default: true + default: string # Default value (only if not required) + example: string # Example value for guidance + +# Components (v1-compatible file-copy approach, optional) +components: + instructions: + - name: string + file: string + description: string + tags: + - string + # ... (same structure as ai-config-kit-package.yaml components) +``` + +### practices[] Field Details + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique identifier within the package | +| `intent` | Yes | One-sentence statement of what behavior or standard this practice enforces | +| `principles` | Yes | List of core rules that define the practice | +| `enforcement_patterns` | No | Concrete patterns the AI should look for or enforce | +| `examples` | No | Illustrative code or workflow examples | +| `tags` | No | List of string tags for filtering | + +### Example + +```yaml +name: python-team-standards +version: 1.0.0 +description: Python development standards for AI coding assistants +author: Platform Team +license: MIT + +practices: + - name: type-safety + intent: Ensure all Python code uses explicit type annotations + principles: + - All function signatures must include parameter and return type hints + - Use built-in generic types (list[str], dict[str, int]) not typing module equivalents + - Avoid Any except where genuinely unavoidable + enforcement_patterns: + - Flag functions missing return type annotations + - Suggest specific types when Any is used + tags: [python, types, mypy] + + - name: testing-standards + intent: Maintain high test coverage with clear, isolated unit tests + principles: + - Write tests before implementation (TDD preferred) + - Each test function covers exactly one behavior + - Use pytest fixtures for shared state, not setUp/tearDown + enforcement_patterns: + - New public functions must have at least one unit test + - Tests must not depend on external services without mocking + tags: [python, testing, pytest] + +mcp_servers: + - name: postgres-explorer + description: Read-only PostgreSQL access for schema exploration + command: npx + args: ["-y", "@modelcontextprotocol/server-postgres"] + credentials: + - name: DATABASE_URL + description: PostgreSQL connection string + required: true + example: postgresql://user:pass@localhost:5432/mydb +``` + +--- + +## ai-config-kit.yaml (v1 instruction repository format) + +The v1 instruction repository manifest. Placed at the root of an instruction repository to define available instructions and bundles. ### Schema @@ -103,9 +215,9 @@ bundles: --- -## ai-config-kit-package.yaml +## ai-config-kit-package.yaml (v1 package format) -The package manifest. Placed at the root of a package directory to define multi-component configuration bundles. +The v1 package manifest. Placed at the root of a package directory to define multi-component configuration bundles. Still supported for backward compatibility -- v1 packages install via file-copy mode. ### Schema @@ -287,138 +399,3 @@ components: size: 45678 ``` ---- - -## templatekit.yaml - -The template repository manifest. Placed at the root of a template repository for use with `devsync template` commands. - -### Schema - -```yaml -# Required fields -name: string # Repository name -description: string # Repository description -version: string # Semantic version - -# Optional fields -author: string # Author or team name - -# Template definitions (at least one required) -templates: - - name: string # Template identifier - description: string # What this template provides - files: # At least one file required - - path: string # Relative path to template file - ide: string # Target IDE: "all", "cursor", "claude", "windsurf", etc. - tags: # Optional tags - - string - dependencies: # Optional: other templates this one requires - - string - -# Bundle definitions (optional, minimum 2 templates per bundle) -bundles: - - name: string # Bundle identifier - description: string # What this bundle provides - templates: # Template names (must reference defined templates) - - string - tags: - - string - -# MCP server definitions (optional) -mcp_servers: - - name: string # Server identifier (alphanumeric, hyphens, underscores) - command: string # Executable command - args: # Command-line arguments - - string - env: # Environment variables (null value = requires user config) - VAR_NAME: string | null - -# MCP sets (optional, named collections of servers) -mcp_sets: - - name: string # Set identifier - description: string # Set purpose - servers: # Server names from mcp_servers - - string -``` - -### File Entry Formats - -Template files can be specified in two formats: - -```yaml -# Simple format (applies to all IDEs) -files: - - instructions/python-style.md - -# Detailed format (IDE-specific) -files: - - path: instructions/python-style.md - ide: cursor - - path: instructions/python-style-claude.md - ide: claude -``` - -Valid `ide` values: `all`, `cursor`, `claude`, `windsurf`, `copilot`, `kiro`, `cline`, `roo`, `codex`, `gemini`, `antigravity`, `amazonq`, `jetbrains`, `junie`, `zed`, `continue`, `aider`, `trae`, `augment`, `tabnine`, `openhands`, `amp`, `opencode`. - -### Validation Rules - -- `name`, `description`, and `version` are required and must be non-empty -- At least one template must be defined -- Each template must have at least one file -- All referenced files must exist in the repository -- Bundle `templates` must reference defined template names -- Bundles must contain at least 2 templates -- Dependencies must reference defined template names (circular dependencies are rejected) -- MCP server names must match `^[a-zA-Z0-9_-]+$` -- MCP environment variable names must match `^[A-Z][A-Z0-9_]*$` - -### Example - -```yaml -name: Company Standards -description: Shared development standards and MCP servers -version: 1.2.0 -author: Platform Engineering - -templates: - - name: code-style - description: Language-agnostic code style rules - files: - - path: templates/code-style.md - ide: all - tags: [style, universal] - - - name: security-rules - description: OWASP-based security guidelines - files: - - path: templates/security-cursor.mdc - ide: cursor - - path: templates/security.md - ide: claude - tags: [security, owasp] - dependencies: - - code-style - -bundles: - - name: full-stack - description: Complete development standards - templates: - - code-style - - security-rules - tags: [full-stack] - -mcp_servers: - - name: internal-api - command: npx - args: ["-y", "@company/mcp-api-server"] - env: - API_TOKEN: null - API_BASE_URL: "https://api.internal.company.com" - -mcp_sets: - - name: backend-dev - description: Backend development servers - servers: - - internal-api -``` diff --git a/docs/tutorials/ai-merge-workflow.md b/docs/tutorials/ai-merge-workflow.md deleted file mode 100644 index ff3417a..0000000 --- a/docs/tutorials/ai-merge-workflow.md +++ /dev/null @@ -1,268 +0,0 @@ -# AI-Assisted Configuration Merging - -**Time to complete**: 20 minutes -**Difficulty**: Advanced -**Prerequisites**: - -- DevSync installed (`pip install devsync`) -- Claude Code installed and configured -- Familiarity with DevSync templates and packages - -!!! warning "Evolving feature" - The AI-assisted merge workflow described in this tutorial is under active development. Commands, interfaces, and capabilities may change between releases. This tutorial reflects the intended workflow direction and will be updated as features stabilize. - -## The Concept - -As teams grow, configuration drift becomes a real problem. Different developers customize their AI assistant rules, new templates override old ones, and project-specific changes conflict with global standards. Manual merging of these configurations is tedious and error-prone. - -**AI-assisted merging** uses the DevSync MCP server (`devsync-mcp`) to give your AI assistant direct access to your DevSync configuration. Instead of manually comparing files and resolving conflicts, you describe what you want in natural language and the AI handles the merge. - -### When to Use AI Merge - -| Situation | Traditional Approach | AI Merge Approach | -|-----------|---------------------|-------------------| -| Template update changes files you customized | Diff files manually, copy sections | Ask AI to merge keeping your customizations | -| Two template namespaces have overlapping rules | Read both, decide which takes precedence | Ask AI to reconcile and deduplicate | -| Migrating from one template repo to another | Uninstall old, install new, re-add custom changes | Ask AI to compare both and produce a merged version | -| Auditing installed configs against current standards | Open each file, compare to source | Ask AI to list all deviations from the source template | - ---- - -## Step 1: Install devsync-mcp - -The `devsync-mcp` package provides an MCP server that exposes DevSync operations as tools your AI assistant can call. - -```bash -pip install devsync-mcp -``` - -Verify the installation: - -```bash -devsync-mcp --version -``` - ---- - -## Step 2: Configure MCP in Claude Code - -Add the DevSync MCP server to your Claude Code configuration. You can do this manually or use DevSync itself. - -### Manual Configuration - -Add the following to your Claude Code MCP settings (typically in `~/.claude/settings.json` or your project's `.claude/settings.local.json`): - -```json -{ - "mcpServers": { - "devsync": { - "command": "devsync-mcp", - "args": ["serve"], - "env": {} - } - } -} -``` - -### Using DevSync - -If your team distributes MCP configurations via DevSync, the `devsync-mcp` server may already be included in your team's MCP template: - -```bash -devsync mcp sync --tool claude -``` - -After configuring, restart Claude Code to pick up the new MCP server. - -### Verify MCP Connection - -In Claude Code, ask: - -``` -What DevSync tools do you have access to? -``` - -The AI should respond with a list of available DevSync operations, such as listing templates, reading configuration files, and comparing versions. - ---- - -## Step 3: Compare Configuration Changes - -Suppose your team has pushed a new version of the standards template, but you have local customizations you want to keep. Use the AI to compare. - -### Start the Conversation - -In Claude Code, within your project directory: - -``` -I updated the team template namespace with `devsync template update team`. -Some of my local customizations in .claude/rules/ may have been overwritten. -Can you compare the current installed files against my Git history -to identify what changed? -``` - -The AI uses the DevSync MCP tools to: - -1. Read the currently installed template files -2. Check Git history for your previous versions -3. Identify differences between the template source and your local modifications - -### Example AI Response - -``` -I found 3 template files in the 'team' namespace. Here's the comparison: - -1. team.python-standards.md - - Template version: v1.2.0 (updated) - - Your customization: Added a "Docstring Format" section (lines 45-62) - - Status: Your customization was overwritten by the update - -2. team.security-policy.md - - Template version: v1.2.0 (unchanged from v1.1.0) - - No local modifications detected - - Status: No action needed - -3. team.review-code.md - - Template version: v1.2.0 (updated) - - Your customization: Added "Performance" checklist section - - Status: Your customization was overwritten by the update - -Would you like me to merge your customizations back into the updated files? -``` - ---- - -## Step 4: Merge with AI Assistance - -Ask the AI to perform the merge: - -``` -Yes, please merge my customizations back into the updated template files. -Keep the new template content as the base, and add my custom sections -where they don't conflict. -``` - -The AI will: - -1. Read the updated template content -2. Retrieve your customizations from Git history -3. Merge the two versions, keeping template updates as the base -4. Add your custom sections in appropriate locations -5. Write the merged files back - -### Example Merge Output - -``` -I've merged your customizations into the updated templates: - -1. team.python-standards.md - - Kept all v1.2.0 template updates - - Re-added your "Docstring Format" section after the "Type Hints" section - - No conflicts detected - -3. team.review-code.md - - Kept all v1.2.0 template updates (added "Accessibility" checklist) - - Re-added your "Performance" checklist section - - Note: The template now has a similar "Performance" section. - I merged both, keeping the more detailed version (yours) - and adding two items from the template version you didn't have. - -Files written: - .claude/rules/team.python-standards.md - .claude/commands/team.review-code.md -``` - ---- - -## Step 5: Review and Approve - -Always review AI-generated merges before committing. The AI does its best, but configuration files affect how your AI assistant behaves across all future interactions. - -### Review the Changes - -```bash -git diff .claude/ -``` - -Or ask the AI to summarize: - -``` -Show me a summary of all changes you made, with before/after for each section. -``` - -### Approve and Commit - -If the merge looks correct: - -```bash -git add .claude/ -git commit -m "chore: merge team template v1.2.0 with local customizations" -``` - -### Revert if Needed - -If the merge introduced problems: - -```bash -git checkout -- .claude/ -``` - -Then try the merge again with more specific instructions: - -``` -Let's try the merge again for team.review-code.md. This time, keep my -Performance section exactly as-is and remove the template's version. -``` - ---- - -## Common AI Merge Workflows - -### Deduplicate Overlapping Templates - -When two namespaces contain similar rules: - -``` -I have templates installed from both the 'company' and 'backend' namespaces. -Some rules overlap (both have Python style guidelines). -Can you identify the overlapping content and suggest a consolidated version? -``` - -### Audit Against Source - -Check if installed files have drifted from their source: - -``` -Compare all installed template files against their source repositories. -List any files where the installed version differs from the source. -``` - -### Migrate Between Template Repositories - -When switching from one template source to another: - -``` -I'm migrating from the 'old-standards' namespace to 'new-standards'. -Both are installed. Can you: -1. Compare the two sets of templates -2. Identify what's new in 'new-standards' that 'old-standards' didn't have -3. Identify customizations I made to 'old-standards' that should carry over -4. Create a migration plan -``` - ---- - -## Limitations - -- **AI merge is advisory**: The AI suggests and applies changes, but you should always review before committing. Configuration files directly affect AI behavior, so mistakes propagate. -- **No automatic conflict resolution**: When two sections genuinely conflict (contradictory rules), the AI will flag the conflict and ask for your decision rather than guessing. -- **Git history required**: The compare-and-merge workflow relies on Git history to identify your customizations. If you haven't been committing your configuration changes, the AI has less context to work with. -- **MCP server availability**: The `devsync-mcp` server must be running and connected. If Claude Code cannot reach it, the AI falls back to reading files directly (which works but loses some DevSync-specific context like namespace and version information). - ---- - -## Next Steps - -- [Create a Team Configuration Repository](team-config-repo.md) -- Set up the templates that feed into this merge workflow -- [Onboard a New Developer](onboard-new-developer.md) -- Complete onboarding walkthrough -- [Build and Distribute a Custom Package](custom-packages.md) -- Create multi-component packages diff --git a/docs/tutorials/ci-cd-integration.md b/docs/tutorials/ci-cd-integration.md deleted file mode 100644 index 602473e..0000000 --- a/docs/tutorials/ci-cd-integration.md +++ /dev/null @@ -1,324 +0,0 @@ -# Enforcing DevSync Standards in CI/CD - -**Time to complete**: 15 minutes -**Difficulty**: Intermediate -**Prerequisites**: - -- DevSync installed (`pip install devsync`) -- A GitHub repository with GitHub Actions enabled -- At least one AI coding assistant configured in the project - -## The Scenario - -Your team uses DevSync to manage AI coding assistant instructions. Every project should have specific instructions installed -- coding standards, security policies, review commands -- but there is no enforcement. Developers forget to install them, or instructions drift out of date. You want your CI/CD pipeline to catch this automatically. - -## What You Will Learn - -- How to define required instructions for a project -- How to validate installed instructions in a CI pipeline -- How to create a GitHub Actions workflow that blocks PRs with missing instructions -- How to add status badges and notifications for compliance - ---- - -## Step 1: Understand the Goal - -DevSync tracks installed instructions in `.devsync/installations.json` at the project root. Your CI pipeline can read this file and compare it against a list of required instructions to determine whether the project is in compliance. - -The workflow is: - -1. Define which instructions are required in a config file -2. In CI, run `devsync list installed --json` to get current state -3. Compare against the requirements -4. Fail the build if any are missing or outdated - -!!! info "Why enforce in CI?" - Local enforcement relies on developers remembering to run checks. CI enforcement is automatic and cannot be bypassed. It also provides a clear audit trail of compliance over time. - ---- - -## Step 2: Define Required Instructions - -Create a file at `.devsync/required.json` in your project root. This file declares which instructions must be installed and optionally which AI tools they must target. - -```json -{ - "version": "1.0", - "required_instructions": [ - { - "name": "python-standards", - "namespace": "team", - "description": "Python coding conventions" - }, - { - "name": "security-policy", - "namespace": "team", - "description": "Security guidelines for all code" - }, - { - "name": "review-code", - "namespace": "team", - "description": "Structured code review command" - } - ], - "required_paths": [ - ".claude/rules/", - ".cursor/rules/" - ] -} -``` - -Commit this file to your repository: - -```bash -git add .devsync/required.json -git commit -m "chore: add required DevSync instructions config" -``` - -!!! tip "Start small" - Begin with a few critical instructions and expand over time. Adding too many requirements at once creates friction for developers who have not adopted DevSync yet. - ---- - -## Step 3: Create the Validation Script - -Create a script that compares installed instructions against the requirements. Save this as `scripts/check-devsync.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -REQUIRED_FILE=".devsync/required.json" -INSTALL_FILE=".devsync/installations.json" - -if [ ! -f "$REQUIRED_FILE" ]; then - echo "No required instructions config found at $REQUIRED_FILE" - echo "Skipping DevSync validation." - exit 0 -fi - -if [ ! -f "$INSTALL_FILE" ]; then - echo "FAIL: No installations.json found." - echo "DevSync instructions have not been installed in this project." - echo "" - echo "Run: devsync install" - exit 1 -fi - -echo "Checking DevSync instruction compliance..." -echo "" - -MISSING=0 - -# Read required instruction names -REQUIRED_NAMES=$(python3 -c " -import json, sys -with open('$REQUIRED_FILE') as f: - data = json.load(f) -for inst in data.get('required_instructions', []): - print(inst['name']) -") - -# Read installed instruction names -INSTALLED_NAMES=$(python3 -c " -import json, sys -with open('$INSTALL_FILE') as f: - data = json.load(f) -for inst in data.get('installations', []): - print(inst.get('instruction_name', inst.get('name', ''))) -") - -for name in $REQUIRED_NAMES; do - if echo "$INSTALLED_NAMES" | grep -q "^${name}$"; then - echo " PASS: $name" - else - echo " FAIL: $name -- not installed" - MISSING=$((MISSING + 1)) - fi -done - -# Check required paths exist -REQUIRED_PATHS=$(python3 -c " -import json -with open('$REQUIRED_FILE') as f: - data = json.load(f) -for p in data.get('required_paths', []): - print(p) -") - -for path in $REQUIRED_PATHS; do - if [ -d "$path" ]; then - FILE_COUNT=$(find "$path" -name "*.md" -o -name "*.mdc" 2>/dev/null | wc -l | tr -d ' ') - echo " PASS: $path ($FILE_COUNT files)" - else - echo " FAIL: $path -- directory does not exist" - MISSING=$((MISSING + 1)) - fi -done - -echo "" - -if [ "$MISSING" -gt 0 ]; then - echo "RESULT: $MISSING requirement(s) not met." - echo "" - echo "To fix, install the missing instructions:" - echo " devsync install" - exit 1 -else - echo "RESULT: All requirements met." - exit 0 -fi -``` - -Make it executable: - -```bash -chmod +x scripts/check-devsync.sh -``` - -Test it locally: - -```bash -./scripts/check-devsync.sh -``` - -Expected output when all instructions are installed: - -``` -Checking DevSync instruction compliance... - - PASS: python-standards - PASS: security-policy - PASS: review-code - PASS: .claude/rules/ (3 files) - PASS: .cursor/rules/ (3 files) - -RESULT: All requirements met. -``` - ---- - -## Step 4: Create the GitHub Actions Workflow - -Create the workflow file at `.github/workflows/devsync-check.yml`: - -```yaml -name: DevSync Compliance - -on: - pull_request: - branches: [main] - paths: - - '.devsync/**' - - '.claude/**' - - '.cursor/**' - - '.windsurf/**' - - '.github/instructions/**' - -jobs: - check-instructions: - name: Verify Required Instructions - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install DevSync - run: pip install devsync - - - name: Validate required instructions - run: | - chmod +x scripts/check-devsync.sh - ./scripts/check-devsync.sh - - - name: List installed instructions - if: always() - run: devsync list installed --json || echo "No installations found" -``` - -!!! warning "Path filters" - The `paths` filter ensures this workflow only runs when DevSync-related files change. If you want it to run on every PR regardless, remove the `paths` section. - ---- - -## Step 5: Require the Check for PR Merges - -To prevent merging PRs that fail the DevSync compliance check: - -1. Go to your repository Settings on GitHub -2. Navigate to **Branches** and select your branch protection rule for `main` -3. Under **Require status checks to pass before merging**, add `Verify Required Instructions` -4. Save changes - -Now any PR that modifies DevSync-related files must pass the compliance check before it can be merged. - ---- - -## Step 6: Add a Status Badge - -Add a badge to your README to show the compliance status at a glance. Add this line to the top of your `README.md`: - -```markdown -[![DevSync Compliance](https://github.com/your-org/your-repo/actions/workflows/devsync-check.yml/badge.svg)](https://github.com/your-org/your-repo/actions/workflows/devsync-check.yml) -``` - -!!! tip "Reference repository" - See [troylar/devsync-starter-templates](https://github.com/troylar/devsync-starter-templates) for an example repository structure that follows these standards. - ---- - -## Step 7: Extend with Notifications (Optional) - -Add a Slack or email notification when the check fails. Append this step to the workflow: - -```yaml - - name: Notify on failure - if: failure() - uses: slackapi/slack-github-action@v1.27.0 - with: - payload: | - { - "text": "DevSync compliance check failed on PR #${{ github.event.pull_request.number }} by ${{ github.actor }}" - } - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} -``` - ---- - -## Troubleshooting - -### Workflow does not trigger - -Verify that the `paths` filter in the workflow matches the files being changed. If no DevSync-related files are in the PR diff, the workflow will not run. Remove the `paths` section to run on every PR. - -### "No installations.json found" in CI - -The `.devsync/installations.json` file must be committed to the repository. After installing instructions locally, commit the tracking file: - -```bash -git add .devsync/installations.json -git commit -m "chore: track DevSync installations" -``` - -### Script fails with "python3 not found" - -Ensure the `actions/setup-python@v5` step runs before the validation script. The Python version must be 3.10 or higher. - -### False failures on new repositories - -If the repository does not yet have DevSync set up, the script exits with code 0 when `.devsync/required.json` does not exist. Once you add the requirements file, enforcement begins. - ---- - -## Next Steps - -- [Using DevSync Across Multiple IDEs](multi-ide-workflow.md) -- Install and sync instructions across all your AI tools -- [Migrating Existing AI Configs to DevSync](migrate-existing-configs.md) -- Bring your existing configuration files under DevSync management -- [Create a Team Configuration Repository](team-config-repo.md) -- Build a shared template repository for your organization diff --git a/docs/tutorials/custom-packages.md b/docs/tutorials/custom-packages.md index e095e74..891cba9 100644 --- a/docs/tutorials/custom-packages.md +++ b/docs/tutorials/custom-packages.md @@ -1,726 +1,190 @@ -# Build and Distribute a Custom Package +# Tutorial: Build Custom Packages -**Time to complete**: 30 minutes -**Difficulty**: Intermediate -**Prerequisites**: +**Time:** 20 minutes | **Level:** Intermediate -- DevSync installed (`pip install devsync`) -- Familiarity with YAML syntax -- At least one AI coding assistant installed - -## What You Will Build - -A complete DevSync package containing: - -- **Instructions** -- Coding guidelines your AI assistant follows -- **An MCP server config** -- External tool integration with credential management -- **A pre-commit hook** -- Automated checks before each commit -- **A slash command** -- A reusable task your AI assistant can execute -- **A resource file** -- A static configuration file installed to the project root - -By the end, you will have a distributable package that any team member can install with one command. - -## What You Will Learn - -- The full `ai-config-kit-package.yaml` manifest format -- How to write each component type -- How to handle credentials securely -- How to test packages locally -- How to distribute packages via Git - ---- - -## Step 1: Create the Directory Structure - -Start by creating the package directory with subdirectories for each component type: - -```bash -mkdir -p my-package/{instructions,mcp,hooks,commands,resources} -cd my-package -``` - -The resulting structure: - -``` -my-package/ -├── instructions/ # AI assistant guidelines -├── mcp/ # MCP server configurations -├── hooks/ # Git and workflow hooks -├── commands/ # Slash commands and scripts -└── resources/ # Static files (configs, templates) -``` - ---- - -## Step 2: Write the Package Manifest - -Create `ai-config-kit-package.yaml` at the root of your package. This file declares every component, its location, and its metadata. - -```yaml -name: backend-dev-kit -version: 1.0.0 -description: Backend development standards with tooling, hooks, and commands -author: Backend Team -license: MIT -namespace: troylar/devsync-python-package - -components: - instructions: - - name: api-design - file: instructions/api-design.md - description: REST API design conventions and patterns - tags: [api, rest, design] - - - name: database-patterns - file: instructions/database-patterns.md - description: Database query patterns and ORM conventions - tags: [database, orm, sql] - - mcp_servers: - - name: github-integration - file: mcp/github.json - description: GitHub API access for PR reviews and issue management - credentials: - - name: GITHUB_PERSONAL_ACCESS_TOKEN - description: GitHub token with repo and read:org scopes - required: true - - hooks: - - name: pre-commit-checks - file: hooks/pre-commit.sh - description: Run linting, type checking, and tests before commits - hook_type: pre-commit - - commands: - - name: run-tests - file: commands/run-tests.sh - description: Execute the test suite with coverage reporting - command_type: shell - - resources: - - name: editorconfig - file: resources/.editorconfig - description: Editor configuration for consistent formatting - checksum: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - size: 350 -``` - -!!! info "Manifest field reference" - - **name**: Package identifier. Use lowercase with hyphens. - - **version**: Semantic version (major.minor.patch). - - **namespace**: Unique identifier, typically `org/package-name`. - - **components**: Contains one or more of: `instructions`, `mcp_servers`, `hooks`, `commands`, `resources`. - - **credentials**: For MCP servers, declares required environment variables. `required: true` means the user must provide a value during configuration. - ---- - -## Step 3: Add Instruction Files - -Instructions are markdown files that tell your AI assistant how to behave. Create two instructions for this package. - -### API Design Instruction - -Create `instructions/api-design.md`: - -```markdown -# REST API Design Conventions - -Follow these conventions for all REST API endpoints in this project. - -## URL Structure - -- Use plural nouns for resources: `/users`, `/orders`, `/products` -- Use kebab-case for multi-word resources: `/order-items`, `/user-profiles` -- Nest related resources: `/users/{id}/orders` -- Limit nesting to two levels maximum - -## HTTP Methods - -| Method | Purpose | Example | Response Code | -|--------|---------|---------|---------------| -| GET | Retrieve resource(s) | `GET /users/123` | 200 | -| POST | Create a resource | `POST /users` | 201 | -| PUT | Full update | `PUT /users/123` | 200 | -| PATCH | Partial update | `PATCH /users/123` | 200 | -| DELETE | Remove a resource | `DELETE /users/123` | 204 | - -## Response Format - -All responses use a consistent envelope: - -```json -{ - "data": {}, - "meta": { - "request_id": "uuid", - "timestamp": "2025-01-15T10:30:00Z" - } -} -``` - -Error responses: - -```json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Human-readable description", - "details": [ - {"field": "email", "message": "Invalid email format"} - ] - }, - "meta": { - "request_id": "uuid" - } -} -``` - -## Pagination - -Use cursor-based pagination for list endpoints: - -``` -GET /users?cursor=abc123&limit=25 -``` - -Response includes pagination metadata: - -```json -{ - "data": [...], - "pagination": { - "next_cursor": "def456", - "has_more": true, - "limit": 25 - } -} -``` - -## When writing API code: - -1. Always validate request body with Pydantic models -2. Return appropriate HTTP status codes -3. Include request_id in all responses for tracing -4. Use cursor-based pagination, not offset-based -5. Document endpoints with OpenAPI docstrings -``` - -### Database Patterns Instruction - -Create `instructions/database-patterns.md`: - -```markdown -# Database Query Patterns - -Follow these patterns for all database operations. - -## Query Construction - -Use the ORM for standard queries. Drop to raw SQL only for complex aggregations -or performance-critical paths. - -```python -from sqlalchemy import select -from sqlalchemy.orm import Session - -def get_active_users(session: Session, limit: int = 100) -> list[User]: - """Retrieve active users ordered by last login.""" - stmt = ( - select(User) - .where(User.is_active == True) - .order_by(User.last_login.desc()) - .limit(limit) - ) - return list(session.scalars(stmt)) -``` - -## Transaction Boundaries - -Keep transactions short. Open a session, do the work, commit or rollback: - -```python -async def create_order(order_data: OrderCreate) -> Order: - """Create an order within a single transaction.""" - async with async_session() as session: - async with session.begin(): - order = Order(**order_data.model_dump()) - session.add(order) - await session.refresh(order) - return order -``` - -## N+1 Query Prevention - -Always use eager loading for known relationships: - -```python -stmt = ( - select(Order) - .options(selectinload(Order.items)) - .where(Order.user_id == user_id) -) -``` - -## When writing database code: - -1. Use the ORM unless raw SQL is justified with a comment -2. Eager-load relationships to avoid N+1 queries -3. Keep transactions as short as possible -4. Add database indexes for columns used in WHERE and ORDER BY -5. Use type hints for all query functions -``` +Build a package with practices, MCP servers, and other components. --- -## Step 4: Add an MCP Server Configuration - -MCP server configurations allow your AI assistant to interact with external services. Create a GitHub integration that enables PR reviews and issue management. - -Create `mcp/github.json`: - -```json -{ - "mcpServers": { - "github": { - "command": "uvx", - "args": ["mcp-server-github"], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" - } - } - } -} -``` +## What You Will Learn -The `${GITHUB_PERSONAL_ACCESS_TOKEN}` placeholder is resolved at sync time from the credentials stored in `.devsync/.env`. The manifest's `credentials` section (Step 2) tells DevSync to prompt the user for this value during `devsync mcp configure`. +- Extract an AI-native v2 package from a project +- Customize the extracted package manifest +- Include MCP server configurations +- Test and distribute the package -!!! warning "Credential security" - Never hardcode tokens or passwords in MCP configuration files. Always use environment variable placeholders. DevSync stores credentials in `.devsync/.env` which is automatically gitignored. +## Prerequisites -!!! note "IDE compatibility" - MCP server components are only installed for IDEs that support them. Currently, Claude Code supports MCP servers. For other IDEs (Cursor, Windsurf, Cline), MCP components are skipped during installation and counted separately in the output. +- DevSync installed (`pip install devsync`) +- LLM configured (`devsync setup`) +- A project with AI rules and/or MCP configurations --- -## Step 5: Add a Pre-Commit Hook - -Hooks are scripts that run automatically at specific points in your workflow. Create a pre-commit hook that runs linting and type checking. - -Create `hooks/pre-commit.sh`: - -```bash -#!/usr/bin/env bash -set -e - -echo "Running pre-commit checks..." - -FAILED=0 - -# Linting -echo " Checking lint (ruff)..." -if command -v ruff &> /dev/null; then - if ! ruff check . --quiet; then - echo " FAIL: Linting errors found. Run 'ruff check --fix .' to auto-fix." - FAILED=1 - else - echo " OK: Lint passed." - fi -else - echo " SKIP: ruff not installed." -fi - -# Type checking -echo " Checking types (mypy)..." -if command -v mypy &> /dev/null; then - if ! mypy . --quiet; then - echo " FAIL: Type errors found." - FAILED=1 - else - echo " OK: Types passed." - fi -else - echo " SKIP: mypy not installed." -fi - -# Formatting -echo " Checking format (black)..." -if command -v black &> /dev/null; then - if ! black --check --quiet .; then - echo " FAIL: Formatting issues. Run 'black .' to fix." - FAILED=1 - else - echo " OK: Format passed." - fi -else - echo " SKIP: black not installed." -fi - -if [ "$FAILED" -ne 0 ]; then - echo "" - echo "Pre-commit checks failed. Fix the issues above before committing." - exit 1 -fi - -echo "" -echo "All pre-commit checks passed." -``` - -Make the script executable: +## Step 1: Extract from Your Project ```bash -chmod +x hooks/pre-commit.sh +cd ~/my-project +devsync extract --output ./my-package --name my-package ``` ---- - -## Step 6: Add a Test Command +This creates a v2 package with practice declarations and MCP configs. -Commands are reusable scripts your AI assistant (or developers) can execute. Create a test runner command. +## Step 2: Review and Customize -Create `commands/run-tests.sh`: +Open the manifest: ```bash -#!/usr/bin/env bash -set -e - -COVERAGE_THRESHOLD="${1:-80}" - -echo "Running test suite..." -echo "Coverage threshold: ${COVERAGE_THRESHOLD}%" -echo "" - -if ! command -v pytest &> /dev/null; then - echo "Error: pytest is not installed." - echo "Install with: pip install pytest pytest-cov" - exit 1 -fi - -pytest \ - --cov=. \ - --cov-report=term-missing \ - --cov-report=html:htmlcov \ - --cov-fail-under="$COVERAGE_THRESHOLD" \ - -v \ - "$@" - -echo "" -echo "Test suite passed." -echo "Coverage report: htmlcov/index.html" +cat my-package/devsync-package.yaml ``` -Make the script executable: - -```bash -chmod +x commands/run-tests.sh -``` - ---- - -## Step 7: Add a Resource File - -Resources are static configuration files installed directly into the project. Create an `.editorconfig` that enforces consistent editor settings. +You can edit the manifest to: -Create `resources/.editorconfig`: +- Rename practices for clarity +- Add or remove principles +- Adjust tags for better categorization +- Add MCP servers not detected automatically -```ini -root = true +### Adding an MCP Server Manually -[*] -charset = utf-8 -end_of_line = lf -indent_style = space -indent_size = 4 -insert_final_newline = true -trim_trailing_whitespace = true +Add to the `mcp_servers` section of the manifest: -[*.py] -indent_size = 4 -max_line_length = 120 - -[*.{yaml,yml}] -indent_size = 2 - -[*.{json,js,ts,tsx}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[Makefile] -indent_style = tab +```yaml +mcp_servers: + - name: postgres-explorer + description: Read-only database access for AI assistants + command: npx + args: ["-y", "@modelcontextprotocol/server-postgres"] + credentials: + - name: DATABASE_URL + description: PostgreSQL connection string + required: true ``` ---- - -## Step 8: Review the Final Structure +## Step 3: Build a v1-Style Package (Alternative) -Your package directory should now look like this: +If you need hooks, commands, or resources, create a v1-format package manually: ``` my-package/ ├── ai-config-kit-package.yaml ├── instructions/ -│ ├── api-design.md -│ └── database-patterns.md +│ └── code-quality.md ├── mcp/ │ └── github.json ├── hooks/ │ └── pre-commit.sh ├── commands/ -│ └── run-tests.sh +│ └── test.sh └── resources/ └── .editorconfig ``` ---- - -## Step 9: Test Locally - -Navigate to a test project and install the package. Use an absolute or relative path to the package directory. - -```bash -cd ~/projects/test-project -devsync package install /path/to/my-package --ide claude -``` - -Expected output: - -``` -Installing package: backend-dev-kit v1.0.0 - -Installed components: - api-design (instruction) -> .claude/rules/api-design.md - database-patterns (instruction) -> .claude/rules/database-patterns.md - github-integration (mcp_server) -> .claude/mcp/github.json - pre-commit-checks (hook) -> .claude/hooks/pre-commit.sh - run-tests (command) -> .claude/commands/run-tests.sh - editorconfig (resource) -> .editorconfig - -6/6 components installed. - -Package 'backend-dev-kit' installed successfully. -``` - -Verify with: - -```bash -devsync package list -``` - -Expected output: - -``` -Installed Packages: - -Package Version Status Components Installed -backend-dev-kit 1.0.0 complete 6 2025-01-15 10:30:00 -``` - -### Test on a Different IDE - -Install to Cursor to see how component filtering works: - -```bash -devsync package install /path/to/my-package --ide cursor -``` - -``` -Installing package: backend-dev-kit v1.0.0 - -Installed components: - api-design (instruction) -> .cursor/rules/api-design.mdc - database-patterns (instruction) -> .cursor/rules/database-patterns.mdc - editorconfig (resource) -> .editorconfig - -3/6 components installed. -Skipped 3 components (not supported by Cursor): - github-integration (mcp_server) - pre-commit-checks (hook) - run-tests (command) - -Package 'backend-dev-kit' installed with status: partial. -``` - -!!! info "IDE capability differences" - Different IDEs support different component types. Instructions and resources are broadly supported. MCP servers, hooks, and commands are currently supported by Claude Code and (partially) Roo Code. Unsupported components are skipped, not treated as errors. - -### Test Conflict Resolution - -If you install the same package again, DevSync detects the existing installation: - -```bash -# Skip existing files (default) -devsync package install /path/to/my-package --ide claude --conflict skip - -# Overwrite existing files -devsync package install /path/to/my-package --ide claude --conflict overwrite - -# Rename new files to avoid conflicts -devsync package install /path/to/my-package --ide claude --conflict rename - -# Force reinstall (uninstalls first, then installs fresh) -devsync package install /path/to/my-package --ide claude --force -``` +```yaml +# ai-config-kit-package.yaml +name: my-package +version: 1.0.0 +description: Custom development package +author: Your Name +license: MIT +namespace: company/my-package -### Test Uninstallation +components: + instructions: + - name: code-quality + file: instructions/code-quality.md + description: Code quality guidelines + tags: [quality] -```bash -devsync package uninstall backend-dev-kit -``` + mcp_servers: + - name: github + file: mcp/github.json + description: GitHub API access + credentials: + - name: GITHUB_TOKEN + description: GitHub personal access token + required: true -``` -Uninstalling package: backend-dev-kit + hooks: + - name: pre-commit + file: hooks/pre-commit.sh + description: Run checks before commits + hook_type: pre-commit -Removed: - .claude/rules/api-design.md - .claude/rules/database-patterns.md - .claude/mcp/github.json - .claude/hooks/pre-commit.sh - .claude/commands/run-tests.sh - .editorconfig + commands: + - name: test + file: commands/test.sh + description: Run test suite + command_type: shell -Package 'backend-dev-kit' uninstalled. 6 files removed. + resources: + - name: editorconfig + file: resources/.editorconfig + description: Editor configuration + install_path: .editorconfig + checksum: sha256:abc123... + size: 280 ``` ---- - -## Step 10: Distribute the Package - -### Option A: Git Repository (Recommended) - -Push the package to a Git repository for version-controlled distribution: +## Step 4: Test the Package ```bash -cd my-package +# Create a test project +mkdir /tmp/test-project && cd /tmp/test-project git init -git add . -git commit -m "feat: initial backend-dev-kit package v1.0.0" -git tag -a v1.0.0 -m "Release v1.0.0" - -gh repo create troylar/devsync-python-package --private --source=. --remote=origin --push -git push origin v1.0.0 -``` - -Team members install directly from the repository URL: -```bash -devsync package install https://github.com/troylar/devsync-python-package --ide claude -``` +# Install the package +devsync install ~/my-project/my-package -### Option B: Shared Directory +# Check what was installed +devsync list -Copy the package to a network share or shared filesystem: +# Verify files +ls -la .claude/rules/ +ls -la .cursor/rules/ -```bash -cp -r my-package /mnt/shared/ai-packages/backend-dev-kit +# Clean up +devsync uninstall my-package --force ``` -Team members install from the shared path: +Test across multiple IDEs: ```bash -devsync package install /mnt/shared/ai-packages/backend-dev-kit --ide claude -``` - -### Option C: Embed in Project Repository - -Include the package directly in your project repository: +# Claude Code gets all component types +devsync install ./my-package --tool claude -```bash -cp -r my-package ~/projects/backend-api/.ai-packages/backend-dev-kit -cd ~/projects/backend-api -git add .ai-packages/ -git commit -m "chore: add backend-dev-kit AI configuration package" +# Cursor gets practices/instructions, MCP, and resources only +devsync install ./my-package --tool cursor ``` -New developers install after cloning: - -```bash -git clone https://github.com/your-org/backend-api -cd backend-api -devsync package install .ai-packages/backend-dev-kit --ide claude -``` - ---- - -## How Team Members Install +## Step 5: Distribute -Share this with your team: +### Via Git ```bash -# Install the package (replace URL with your repository) -devsync package install https://github.com/troylar/devsync-python-package --ide claude - -# If the package includes MCP servers, configure credentials -devsync mcp configure your-org - -# Sync MCP to AI tools -devsync mcp sync --tool all - -# Verify -devsync package list -``` - -When the package is updated, team members can reinstall: - -```bash -devsync package install https://github.com/troylar/devsync-python-package --ide claude --force -``` - ---- - -## Troubleshooting - -### "Manifest validation failed" - -Check that `ai-config-kit-package.yaml` has all required fields (`name`, `version`, `description`, `author`, `namespace`) and that all `file` paths reference files that exist in the package directory. - -### "Component file not found" - -The `file` path in the manifest is relative to the package root. Verify the file exists: - -```bash -ls -la instructions/api-design.md -``` - -### "Package already installed" - -Use `--force` to overwrite or `--conflict overwrite` to replace individual files: - -```bash -devsync package install ./my-package --ide claude --force -``` - -### MCP credentials not prompting - -Ensure the `credentials` section in the manifest lists the required environment variables: - -```yaml -credentials: - - name: GITHUB_PERSONAL_ACCESS_TOKEN - description: GitHub token with repo scope - required: true +cd my-package +git init && git add . && git commit -m "Initial package" +git remote add origin https://github.com/company/my-package +git push -u origin main ``` -### Hook not executing - -Verify the hook file is executable: +### Via Shared Directory ```bash -chmod +x hooks/pre-commit.sh +cp -r ./my-package /shared/packages/ ``` -After installation, check the installed copy: +### Inside a Project Repo ```bash -ls -la .claude/hooks/pre-commit.sh +# Include as a subdirectory +cp -r ./my-package ~/my-project/.devsync-package/ ``` --- ## Next Steps -- [Create a Team Configuration Repository](team-config-repo.md) -- Distribute templates (a lighter-weight alternative to packages) -- [Onboard a New Developer](onboard-new-developer.md) -- Use packages as part of developer onboarding -- [AI-Assisted Configuration Merging](ai-merge-workflow.md) -- Use AI to manage package update conflicts -- [YAML Schemas Reference](../reference/yaml-schemas.md) -- Complete YAML schema reference -- [Package Examples](../packages/examples.md) -- Real-world package examples +- [Package Examples](../packages/examples.md) -- complete real-world package examples +- [Component Types](../packages/components.md) -- detailed reference for each component +- [Multi-IDE Workflow](multi-ide-workflow.md) -- managing across multiple IDEs diff --git a/docs/tutorials/migrate-existing-configs.md b/docs/tutorials/migrate-existing-configs.md index 9ebda7e..8ede218 100644 --- a/docs/tutorials/migrate-existing-configs.md +++ b/docs/tutorials/migrate-existing-configs.md @@ -1,395 +1,173 @@ -# Migrating Existing AI Configs to DevSync +# Tutorial: Migrate Existing Configs -**Time to complete**: 20 minutes -**Difficulty**: Intermediate -**Prerequisites**: +**Time:** 15 minutes | **Level:** Intermediate -- DevSync installed (`pip install devsync`) -- Existing AI configuration files in one or more projects (e.g., `.cursor/rules/`, `.claude/rules/`, `AGENTS.md`) -- Git available for repository creation - -## The Scenario - -You have been using AI coding assistants for a while. Over time, you have accumulated configuration files -- cursor rules, claude instructions, copilot guidelines -- scattered across projects. Some were written by hand, some copied from colleagues, some downloaded from the internet. You want to bring these under DevSync management so they can be versioned, shared, and kept in sync. - -## What You Will Learn - -- How to audit your existing AI configuration files -- How to create a `templatekit.yaml` manifest from existing instructions -- How to create an `ai-config-kit-package.yaml` for complex setups with hooks and commands -- How to set up a Git repository for distribution -- How to handle the transition period on a team -- How to clean up old manual configurations +Convert your existing AI tool configurations into a shareable DevSync package. --- -## Step 1: Audit Your Current Configuration Files - -Start by finding all AI configuration files in your project. Check the standard directories: - -```bash -# Claude Code -ls -la .claude/rules/ 2>/dev/null -ls -la .claude/commands/ 2>/dev/null -ls -la .claude/hooks/ 2>/dev/null - -# Cursor -ls -la .cursor/rules/ 2>/dev/null - -# Windsurf -ls -la .windsurf/rules/ 2>/dev/null - -# GitHub Copilot -ls -la .github/instructions/ 2>/dev/null - -# Cline -ls -la .clinerules/ 2>/dev/null - -# Codex CLI -ls -la AGENTS.md 2>/dev/null -``` +## What You Will Learn -Make a list of what you find. For example: +- Audit existing AI tool configurations +- Extract them into a DevSync package +- Upgrade v1 packages to v2 format +- Distribute to your team -``` -Found: - .claude/rules/coding-standards.md - .claude/rules/api-conventions.md - .claude/commands/review-pr.md - .cursor/rules/coding-standards.mdc - .cursor/rules/api-conventions.mdc -``` +## Prerequisites -!!! info "Duplicate content across IDEs" - It is common to find the same instructions duplicated across IDE directories with minor format differences. DevSync eliminates this duplication by maintaining a single source and translating to each IDE's format during installation. +- DevSync installed (`pip install devsync`) +- LLM configured (`devsync setup`) +- An existing project with AI rules, MCP configs, or other tool-specific files --- -## Step 2: Organize Files into a Template Repository +## Step 1: Audit Existing Configs -Create a new directory for your template repository and copy your existing instruction files into it: +Check what AI configurations your project already has: ```bash -mkdir -p my-templates/templates/rules -mkdir -p my-templates/templates/commands +devsync tools ``` -Copy your instruction files. Use the Markdown (`.md`) versions as the canonical source, since DevSync converts to other formats automatically: +Then look at the tool-specific directories: ```bash -cp .claude/rules/coding-standards.md my-templates/templates/rules/ -cp .claude/rules/api-conventions.md my-templates/templates/rules/ -cp .claude/commands/review-pr.md my-templates/templates/commands/ -``` - -!!! warning "Cursor .mdc files" - If you only have `.mdc` files from Cursor and no `.md` equivalents, rename the copies to `.md`. The `.mdc` format is similar to Markdown, but you may need to remove any Cursor-specific frontmatter (lines between `---` markers at the top of the file) to create a clean portable version. - -Your directory should now look like this: - -``` -my-templates/ -└── templates/ - ├── rules/ - │ ├── coding-standards.md - │ └── api-conventions.md - └── commands/ - └── review-pr.md -``` - ---- - -## Step 3: Create the templatekit.yaml Manifest - -Create `my-templates/templatekit.yaml` to describe your instructions: - -```yaml -name: my-team-templates -version: 1.0.0 -description: Team coding standards and workflows migrated from existing configs - -templates: - - name: coding-standards - description: Language-agnostic coding conventions and formatting rules - ide: claude - files: - - path: .claude/rules/coding-standards.md - type: instruction - tags: [standards, style] - - - name: api-conventions - description: REST API design patterns and naming conventions - ide: claude - files: - - path: .claude/rules/api-conventions.md - type: instruction - tags: [api, rest, design] - - - name: review-pr - description: Structured pull request review checklist - ide: claude - files: - - path: .claude/commands/review-pr.md - type: command - tags: [review, workflow] -``` - -!!! tip "Map your existing files" - Each entry in `templates` corresponds to one of your existing instruction files. The `name` should be descriptive and kebab-cased. The `files.path` defines where DevSync will install the file in target projects. - ---- - -## Step 4: Create a Package for Complex Setups (Optional) - -If your existing configuration includes more than just instruction files -- for example, hooks, shell commands, or MCP server configurations -- create an `ai-config-kit-package.yaml` instead of (or in addition to) the template manifest. +# Claude Code +ls .claude/rules/ 2>/dev/null -```yaml -name: my-team-package -version: 1.0.0 -description: Complete team configuration including standards, hooks, and commands -author: Your Team -license: MIT +# Cursor +ls .cursor/rules/ 2>/dev/null -components: - instructions: - - name: coding-standards - file: instructions/coding-standards.md - description: Language-agnostic coding conventions - tags: [standards, style] - - - name: api-conventions - file: instructions/api-conventions.md - description: REST API design patterns - tags: [api, design] - - hooks: - - name: pre-commit-lint - file: hooks/pre-commit-lint.sh - description: Run linting before each commit - hook_type: pre-commit - - commands: - - name: review-pr - file: commands/review-pr.md - description: Structured PR review checklist - command_type: slash - - resources: - - name: editorconfig - file: resources/.editorconfig - description: Editor configuration for consistent formatting -``` +# Windsurf +ls .windsurf/rules/ 2>/dev/null -The package directory structure: +# GitHub Copilot +ls .github/instructions/ 2>/dev/null +# Single-file configs +ls AGENTS.md CONVENTIONS.md GEMINI.md 2>/dev/null ``` -my-team-package/ -├── ai-config-kit-package.yaml -├── instructions/ -│ ├── coding-standards.md -│ └── api-conventions.md -├── hooks/ -│ └── pre-commit-lint.sh -├── commands/ -│ └── review-pr.md -└── resources/ - └── .editorconfig -``` - -!!! info "Packages vs. templates" - Use a `templatekit.yaml` for instruction-only setups. Use `ai-config-kit-package.yaml` when you need to bundle hooks, commands, MCP servers, or resource files alongside instructions. Both can be distributed via Git. - ---- -## Step 5: Set Up the Git Repository +## Step 2: Extract into a Package -Initialize the repository and push it to GitHub: +DevSync reads all detected configs and produces a package: ```bash -cd my-templates -git init -git add . -git commit -m "feat: initial migration of team AI configurations" +devsync extract --output ./migrated-standards --name my-standards ``` -Create the remote repository: - -```bash -gh repo create your-org/team-ai-configs --private --source=. --remote=origin --push -``` - -Tag the first version: - -```bash -git tag -a v1.0.0 -m "v1.0.0: Initial migration from manual configs" -git push origin v1.0.0 ``` +Extracting practices from /home/user/my-project... -!!! tip "Reference structure" - See [troylar/devsync-starter-templates](https://github.com/troylar/devsync-starter-templates) for an example of a well-structured template repository. Use it as a reference for directory layout and manifest format. - ---- - -## Step 6: Install from the New Repository - -Test the installation in a project to verify everything works: - -```bash -cd ~/projects/my-project - -# For template repositories -devsync template install https://github.com/your-org/team-ai-configs --as team + Scanning: .claude/rules/ (4 files) + Scanning: .cursor/rules/ (3 files) + Scanning: MCP configurations (2 servers) -# For package repositories -devsync package install https://github.com/your-org/team-ai-configs --ide claude -``` - -Expected output for a template install: + Extracted 5 practice declarations + Extracted 2 MCP servers +Package written to: ./migrated-standards/devsync-package.yaml ``` -Installing templates from https://github.com/your-org/team-ai-configs... -Namespace: team -Installed: - team.coding-standards -> .claude/rules/team.coding-standards.md - team.api-conventions -> .claude/rules/team.api-conventions.md - team.review-pr -> .claude/commands/team.review-pr.md - -3 templates installed successfully. -``` +## Step 3: Review the Package -Verify by listing installed templates: +Check the extracted practices: ```bash -devsync template list -``` - ---- - -## Step 7: Distribute to Your Team - -Share the repository URL with your team. Provide clear instructions in your project README or onboarding docs: - -```markdown -## AI Assistant Setup - -Install team coding standards for your AI assistant: - - devsync template install https://github.com/your-org/team-ai-configs --as team - -This installs coding standards, API conventions, and review commands -to your AI assistant's configuration directory. +cat migrated-standards/devsync-package.yaml ``` -### Handling the Transition Period - -Not every team member will adopt DevSync on the same day. During the transition: +Verify that: -1. **Keep manual configs committed.** Do not delete `.claude/rules/` or `.cursor/rules/` files from the repository immediately. Team members who have not installed DevSync still need them. +- Practice names are clear and descriptive +- Principles accurately capture your standards +- MCP server credentials are properly declared (not hardcoded) -2. **Add a deprecation notice.** Add a comment to the top of manually maintained config files: +Edit the manifest if needed to refine the extracted content. - ```markdown - - ``` +## Step 4: Test the Package -3. **Set a migration deadline.** Announce a date by which all team members should have DevSync installed. After that date, remove the manually maintained config files. - -4. **Use CI enforcement.** Once most of the team has migrated, add a [CI check](ci-cd-integration.md) that validates DevSync installations. Start with warnings, then switch to blocking. - -!!! warning "Namespace conflicts" - If team members have existing files with the same names as DevSync-managed ones, they will encounter conflicts during installation. Use `--conflict overwrite` to replace old files, or `--conflict rename` to keep both during the transition. - ---- - -## Step 8: Clean Up Old Manual Configs - -Once the entire team has migrated to DevSync, remove the old manually maintained configuration files: +Install into a clean project to verify: ```bash -# Remove old manual configs (DevSync now manages these) -git rm .claude/rules/coding-standards.md -git rm .claude/rules/api-conventions.md -git rm .claude/commands/review-pr.md -git rm .cursor/rules/coding-standards.mdc -git rm .cursor/rules/api-conventions.mdc - -git commit -m "chore: remove manually maintained AI configs, now managed by DevSync" +mkdir /tmp/test-migration && cd /tmp/test-migration +git init +devsync install ~/my-project/migrated-standards +devsync list ``` -After this commit, the only AI configuration files in the project will be the ones installed and tracked by DevSync. +## Step 5: Distribute -Verify the clean state: +Push the package to Git for your team: ```bash -devsync template list -``` - -``` -Installed Templates: - -Namespace Name Type Scope Source -team coding-standards instruction project github.com/your-org/team-ai-configs -team api-conventions instruction project github.com/your-org/team-ai-configs -team review-pr command project github.com/your-org/team-ai-configs +cd migrated-standards +git init && git add . && git commit -m "Initial migration" +git remote add origin https://github.com/company/team-standards +git push -u origin main ``` --- -## Troubleshooting +## Upgrading v1 Packages -### "Namespace already exists" during install - -A previous installation attempt may have partially completed. Use `--force` to overwrite: +If you have an existing v1 package (`ai-config-kit-package.yaml`), upgrade it to v2: ```bash -devsync template install https://github.com/your-org/team-ai-configs --as team --force +devsync extract --upgrade ./old-v1-package --output ./v2-package --name my-standards ``` -### Cursor files not working after migration +This converts v1 instructions into v2 practice declarations while preserving MCP server configurations. -Ensure you are installing to Cursor specifically: +### Before (v1) -```bash -devsync install coding-standards --tool cursor +```yaml +# ai-config-kit-package.yaml +name: old-package +version: 1.0.0 +components: + instructions: + - name: python-style + file: instructions/python-style.md + description: Python style guide ``` -DevSync automatically converts `.md` source files to `.mdc` format for Cursor. If conversion fails, check that the source Markdown does not contain syntax that is incompatible with the `.mdc` format. - -### Existing files conflict with DevSync installations +### After (v2) -During the transition, you may have both manually created and DevSync-managed files. Use `--conflict rename` to keep both: - -```bash -devsync install coding-standards --tool claude --conflict rename +```yaml +# devsync-package.yaml +name: my-standards +version: 1.0.0 +practices: + - name: python-style + intent: Consistent Python code formatting and naming + principles: + - Line length 120 characters + - Use black for formatting + - snake_case for functions + tags: [python, style] ``` -This installs the DevSync version alongside the existing file so you can compare them before removing the old one. +--- -### Team members see different instruction versions +## File-Copy Mode -This usually means the local library is out of date. Have each team member update: +If you don't have an LLM configured, use file-copy mode: ```bash -devsync update your-org/team-ai-configs -``` +# Extract files verbatim +devsync extract --output ./pkg --name my-pkg --no-ai -Then reinstall: - -```bash -devsync template install https://github.com/your-org/team-ai-configs --as team --force +# Install files verbatim +devsync install ./pkg --no-ai ``` -### Package install skips some components - -Different IDEs support different component types. For example, Cursor only supports instructions and resources -- hooks and commands will be skipped. Check the [IDE capability matrix](multi-ide-workflow.md#step-4-understand-file-format-differences) to see what each IDE supports. +This copies files directly without AI extraction or adaptation. --- ## Next Steps -- [Create a Team Configuration Repository](team-config-repo.md) -- Build a more comprehensive template repository from scratch -- [Enforcing DevSync Standards in CI/CD](ci-cd-integration.md) -- Automate compliance checks in your CI pipeline -- [Build and Distribute a Custom Package](custom-packages.md) -- Create packages with MCP servers, hooks, and more +- [Team Config Repository](team-config-repo.md) -- host and maintain your standards +- [Custom Packages](custom-packages.md) -- add hooks, commands, and resources +- [Package Examples](../packages/examples.md) -- see complete package examples diff --git a/docs/tutorials/multi-ide-workflow.md b/docs/tutorials/multi-ide-workflow.md index dfc8f1f..68e5d82 100644 --- a/docs/tutorials/multi-ide-workflow.md +++ b/docs/tutorials/multi-ide-workflow.md @@ -1,259 +1,137 @@ -# Using DevSync Across Multiple IDEs +# Tutorial: Multi-IDE Workflow -**Time to complete**: 10 minutes -**Difficulty**: Beginner -**Prerequisites**: +**Time:** 10 minutes | **Level:** Beginner -- DevSync installed (`pip install devsync`) -- Two or more AI coding assistants installed (e.g., Claude Code, Cursor, GitHub Copilot) - -## The Scenario - -You use multiple AI coding assistants throughout the day -- perhaps Claude Code for complex refactoring, Cursor for quick edits, and GitHub Copilot for inline suggestions. Each tool has its own configuration directory and file format. You want the same coding standards applied across all of them without manually duplicating files. - -## What You Will Learn - -- How to detect which AI tools are available on your machine -- How to install the same instructions to multiple IDEs at once -- How each IDE stores its configuration files and what format differences exist -- How to keep instructions in sync when updating -- When to use packages versus individual instructions +Install the same coding standards across multiple AI coding tools simultaneously. --- -## Step 1: Detect Available Tools - -DevSync auto-detects installed AI coding assistants. Run: - -```bash -devsync tools -``` +## What You Will Learn -Expected output (varies by machine): +- How DevSync auto-detects installed tools +- How packages adapt to different IDE formats +- How to target specific tools -``` -Detected AI Tools: - -Tool Status Install Path -Claude Code detected .claude/rules/ -Cursor detected .cursor/rules/ -GitHub Copilot detected .github/instructions/ -Windsurf detected .windsurf/rules/ -Cline not found -Roo Code not found -Codex CLI not found -Kiro not found - -4 tool(s) detected. -``` +## Prerequisites -!!! info "Detection logic" - DevSync checks for the presence of each tool's CLI or application on your system. A tool showing as "detected" means DevSync can install configurations for it. You do not need to have the tool actively running. +- DevSync installed (`pip install devsync`) +- Two or more AI coding tools installed --- -## Step 2: Download Instructions to Your Library - -Download a template repository to your local library. This tutorial uses the starter templates as an example: +## Step 1: Detect Your Tools ```bash -devsync download https://github.com/troylar/devsync-starter-templates +devsync tools ``` -Expected output: - ``` -Cloning repository... -Repository downloaded to library: ~/.devsync/library/troylar/devsync-starter-templates +AI Coding Tools +┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ +┃ Tool ┃ Status ┃ +┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ +│ Claude Code │ ✓ Installed │ +│ Cursor │ ✓ Installed │ +│ GitHub Copilot │ ✓ Installed │ +│ Windsurf │ ✗ Not found │ +│ Kiro │ ✗ Not found │ +└─────────────────────┴───────────────┘ -Instructions found: - python-standards Python coding conventions - security-policy Security guidelines - code-review Structured review checklist +Found 3 installed tool(s) ``` -Browse what is available: +## Step 2: Install to All Detected Tools ```bash -devsync list available +devsync install ./team-standards ``` -This displays all instructions in your local library, ready to install. - ---- - -## Step 3: Install to Multiple IDEs - -Install an instruction to a specific IDE using the `--tool` flag: - -```bash -cd ~/projects/my-project - -# Install to Claude Code -devsync install python-standards --tool claude +DevSync automatically installs to every detected tool, adapting the format: -# Install to Cursor -devsync install python-standards --tool cursor - -# Install to GitHub Copilot -devsync install python-standards --tool copilot ``` +Installing team-standards... -Each command places the instruction file in the correct directory with the correct format for that IDE. + Claude Code: + Created: .claude/rules/code-style.md + Created: .claude/rules/testing.md -To install to all detected IDEs at once, use the interactive TUI: + Cursor: + Created: .cursor/rules/code-style.mdc + Created: .cursor/rules/testing.mdc -```bash -devsync install + GitHub Copilot: + Created: .github/instructions/code-style.instructions.md + Created: .github/instructions/testing.instructions.md ``` -The TUI lets you select instructions from your library and choose which IDEs to target. Selected instructions are installed to all chosen tools simultaneously. - -!!! tip "Batch installation" - If you have many instructions to install, the TUI is faster than running individual commands. It shows a unified view of your library and lets you select multiple instructions at once. - ---- - -## Step 4: Understand File Format Differences +## File Format Differences -Each IDE expects its configuration files in a specific format and location. DevSync handles these translations automatically. +Each IDE has its own format. DevSync handles the translation: | IDE | Directory | Extension | Notes | |-----|-----------|-----------|-------| -| Claude Code | `.claude/rules/` | `.md` | Standard Markdown | -| Cursor | `.cursor/rules/` | `.mdc` | Cursor-specific Markdown variant | -| Cline | `.clinerules/` | `.md` | Standard Markdown | -| Kiro | `.kiro/steering/` | `.md` | Standard Markdown | -| Roo Code | `.roo/rules/` | `.md` | Standard Markdown | -| Windsurf | `.windsurf/rules/` | `.md` | Standard Markdown | -| GitHub Copilot | `.github/instructions/` | `.md` | Standard Markdown | -| Codex CLI | `AGENTS.md` | `.md` | Sections within a single file | +| Claude Code | `.claude/rules/` | `.md` | Standard markdown | +| Cursor | `.cursor/rules/` | `.mdc` | Cursor markdown format | +| Windsurf | `.windsurf/rules/` | `.md` | Standard markdown | +| GitHub Copilot | `.github/instructions/` | `.instructions.md` | Special extension | +| Kiro | `.kiro/steering/` | `.md` | Standard markdown | +| Cline | `.clinerules/` | `.md` | Standard markdown | +| Roo Code | `.roo/rules/` | `.md` | Standard markdown | +| Codex CLI | `AGENTS.md` | Section markers | Single-file with sections | -After installing `python-standards` to three IDEs, your project structure looks like this: +## Step 3: Target Specific Tools -``` -my-project/ -├── .claude/ -│ └── rules/ -│ └── python-standards.md -├── .cursor/ -│ └── rules/ -│ └── python-standards.mdc -├── .github/ -│ └── instructions/ -│ └── python-standards.md -├── .devsync/ -│ └── installations.json -└── ... (project files) -``` - -!!! warning "Cursor's .mdc format" - Cursor uses `.mdc` files, which are similar to Markdown but may include Cursor-specific frontmatter. DevSync converts standard Markdown instructions to the `.mdc` format automatically. Do not rename `.mdc` files to `.md` -- Cursor will not pick them up. - ---- - -## Step 5: Keep Instructions in Sync - -When the source repository is updated, you can pull the latest changes and reinstall: +Install to specific tools only: ```bash -# Update your local library -devsync update troylar/devsync-starter-templates - -# Reinstall to all IDEs -devsync install python-standards --tool claude -devsync install python-standards --tool cursor -devsync install python-standards --tool copilot -``` - -DevSync detects that the files already exist and prompts you with conflict resolution options: +# Just Claude Code and Cursor +devsync install ./team-standards --tool claude --tool cursor +# Just Copilot +devsync install ./team-standards --tool copilot ``` -File already exists: .claude/rules/python-standards.md -Choose action: - [s] Skip -- keep the existing file - [o] Overwrite -- replace with the updated version - [r] Rename -- install as python-standards-1.md - -Your choice: -``` - -To skip the prompt and always overwrite, use the `--conflict overwrite` flag: +## Step 4: Verify Across Tools ```bash -devsync install python-standards --tool claude --conflict overwrite +devsync list ``` ---- - -## Step 6: When to Use Packages vs. Individual Instructions - -DevSync supports two installation modes: - -**Individual instructions** are single files installed one at a time. Use them when: - -- You only need coding standards or guidelines -- Different projects need different subsets of instructions -- You want fine-grained control over what is installed - -**Packages** are bundles containing multiple component types (instructions, MCP servers, hooks, commands, resources). Use them when: - -- You need to install a coordinated set of components -- MCP server configurations or hooks are involved -- You want one-command installation of a complete setup - -Install a package with: +Check individual IDE directories: ```bash -devsync package install https://github.com/troylar/devsync-python-package --ide claude +ls .claude/rules/ +ls .cursor/rules/ +ls .github/instructions/ ``` -!!! info "Package IDE support" - Not all IDEs support all component types. For example, Claude Code supports instructions, MCP servers, hooks, commands, and resources. Cursor only supports instructions and resources. DevSync automatically skips unsupported components and reports what was installed versus what was skipped. +## Component Support by IDE -To see what a package contains before installing: +Not all IDEs support all component types. DevSync automatically skips unsupported components: -```bash -devsync package install https://github.com/troylar/devsync-python-package --ide claude --dry-run -``` +| Component | Claude | Cursor | Windsurf | Copilot | Kiro | Cline | Roo | +|-----------|:------:|:------:|:--------:|:-------:|:----:|:-----:|:---:| +| Practices | Y | Y | Y | Y | Y | Y | Y | +| MCP Servers | Y | Y | Y | Y | -- | -- | Y | +| Hooks | Y | -- | -- | -- | -- | -- | -- | +| Commands | Y | -- | -- | -- | -- | -- | Y | +| Resources | Y | Y | Y | -- | Y | Y | Y | --- -## Troubleshooting - -### "No AI tools detected" - -Install at least one supported AI coding assistant and ensure it is on your system PATH. Run `devsync tools` after installation to confirm detection. - -### Instruction installed but IDE does not use it +## Keeping Tools in Sync -Some IDEs require a restart or project reload to pick up new configuration files. Close and reopen the IDE, or reload the project window. - -### Cursor ignores the installed file - -Verify the file has the `.mdc` extension and is located in `.cursor/rules/`. Cursor does not read `.md` files from this directory. - -### Conflict on every install - -If you frequently update instructions, use `--conflict overwrite` to avoid repeated prompts: +When you update your package, reinstall to all tools: ```bash -devsync install python-standards --tool claude --conflict overwrite +devsync install ./team-standards --conflict overwrite ``` -### Instructions appear in one IDE but not another - -Run `devsync list installed` to see which tools each instruction was installed to. You may need to install explicitly for each tool: - -```bash -devsync install python-standards --tool cursor -``` +This updates all detected tools at once. --- ## Next Steps -- [Enforcing DevSync Standards in CI/CD](ci-cd-integration.md) -- Validate instruction compliance in your CI pipeline -- [Migrating Existing AI Configs to DevSync](migrate-existing-configs.md) -- Bring existing config files under DevSync management -- [Build and Distribute a Custom Package](custom-packages.md) -- Create multi-component packages for your team +- [Team Config Repository](team-config-repo.md) -- share standards via Git +- [IDE Integrations](../ide-integrations/index.md) -- detailed guide for each IDE diff --git a/docs/tutorials/onboard-new-developer.md b/docs/tutorials/onboard-new-developer.md index d538379..1a9c37e 100644 --- a/docs/tutorials/onboard-new-developer.md +++ b/docs/tutorials/onboard-new-developer.md @@ -1,465 +1,164 @@ -# Onboard a New Developer +# Tutorial: Onboard a New Developer -**Time to complete**: 15 minutes -**Difficulty**: Beginner -**Prerequisites**: +**Time:** 10 minutes | **Level:** Beginner -- Python 3.10+ installed -- Git configured with access to your organization's repositories -- At least one AI coding assistant installed (Claude Code, Cursor, Windsurf, etc.) - -## The Scenario +Get a new team member fully configured with your team's AI coding standards. -A new developer is joining your team. They need their AI coding assistants configured with: +--- -- **Company-wide standards** that apply to every project (global scope) -- **Project-specific templates** for the codebase they will work on -- **MCP server configurations** for shared tooling (GitHub, database access, etc.) +## What You Will Learn -This tutorial walks through the complete setup from a clean machine to a fully configured development environment. +- Install DevSync and detect AI tools +- Install team standards from a Git repository +- Verify the installation -## What You Will Learn +## Prerequisites -- How to install and verify DevSync -- How to detect which AI tools are available -- How to install global and project-scoped templates -- How to install and configure MCP servers -- How to verify the complete setup +- Python 3.10+ installed +- One or more AI coding tools installed (Claude Code, Cursor, etc.) +- Your team's standards package hosted in a Git repository --- ## Step 1: Install DevSync -Install DevSync from PyPI: - ```bash pip install devsync ``` -Verify the installation: +Verify: ```bash -devsync --version +devsync version ``` -!!! tip "Virtual environments" - If your organization uses virtual environments or `pipx`, adjust accordingly: - - ```bash - # With pipx (recommended for CLI tools) - pipx install devsync - - # With a virtual environment - python -m venv ~/.venvs/devsync - ~/.venvs/devsync/bin/pip install devsync - alias devsync="~/.venvs/devsync/bin/devsync" - ``` +## Step 2: Configure LLM (Optional) ---- - -## Step 2: Check Detected AI Tools - -DevSync auto-detects which AI coding assistants are installed on your machine. Run: +For AI-powered adaptation: ```bash -devsync tools -``` - -Expected output (varies by machine): - -``` -Detected AI Tools: - -Tool Status Install Path -Claude Code detected .claude/rules/ -Cursor detected .cursor/rules/ -Windsurf detected .windsurf/rules/ -GitHub Copilot detected .github/instructions/ -Cline not found -Roo Code not found -Codex CLI not found -Kiro not found - -4 tool(s) detected. +devsync setup ``` -!!! note "At least one tool required" - DevSync needs at least one detected tool to install configurations. If no tools appear, install one of the supported AI coding assistants first. - ---- +!!! info + If the new developer doesn't have an API key, they can skip this step. Installation still works using `--no-ai` for file-copy mode. -## Step 3: Install Company-Wide Global Standards - -Global standards apply to every project on your machine. These typically include company coding conventions, security policies, and communication guidelines. +## Step 3: Detect AI Tools ```bash -devsync template install https://github.com/troylar/devsync-starter-templates --as company --scope global +devsync tools ``` Expected output: ``` -Installing templates from https://github.com/troylar/devsync-starter-templates... -Namespace: company -Scope: global - -Installed: - company.coding-conventions -> ~/.claude/rules/company.coding-conventions.md - company.security-policy -> ~/.claude/rules/company.security-policy.md - company.commit-standards -> ~/.claude/rules/company.commit-standards.md - -3 templates installed to global scope. -``` - -The `--scope global` flag installs templates to your home directory (`~/.claude/rules/`) rather than a specific project. This means these standards are active in every project you open. - -Verify the global installation: - -```bash -devsync template list -``` - -``` -Installed Templates: - -Namespace Name Type Scope Source -company coding-conventions instruction global github.com/troylar/devsync-starter-templates -company security-policy instruction global github.com/troylar/devsync-starter-templates -company commit-standards instruction global github.com/troylar/devsync-starter-templates -``` - ---- - -## Step 4: Clone the Project Repository - -Clone the project you will be working on: - -```bash -cd ~/projects -git clone https://github.com/your-company/backend-api -cd backend-api +Detected AI Tools: + Claude Code .claude/rules/ (.md) + Cursor .cursor/rules/ (.mdc) ``` ---- - -## Step 5: Install Project-Specific Templates +## Step 4: Install Team Standards -Project-specific templates contain conventions unique to this codebase: API design patterns, database conventions, deployment procedures, and so on. +Navigate to the project and install: ```bash -devsync template install https://github.com/troylar/devsync-python-package --as backend -``` - -Expected output: - +cd ~/the-project +devsync install https://github.com/company/team-standards ``` -Installing templates from https://github.com/troylar/devsync-python-package... -Namespace: backend - -Installed: - backend.api-design -> .claude/rules/backend.api-design.md - backend.database-patterns -> .claude/rules/backend.database-patterns.md - backend.review-pr -> .claude/commands/backend.review-pr.md - backend.run-migrations -> .claude/commands/backend.run-migrations.md - -4 templates installed successfully. -``` - -!!! info "Project scope is the default" - Without `--scope global`, templates install at the project level. They are stored in the project's `.claude/rules/` and `.claude/commands/` directories, scoped to that project only. - ---- -## Step 6: Install MCP Server Configurations +DevSync will: -MCP (Model Context Protocol) servers extend your AI assistant with additional capabilities like GitHub API access, database queries, or file system operations. Your team distributes MCP configurations via Git, and DevSync handles installation and credential management. +1. Clone the team standards repository +2. Read the practice declarations +3. Detect installed AI tools +4. Adapt practices to the project's existing setup +5. Prompt for any MCP server credentials -```bash -devsync mcp install https://github.com/troylar/devsync-python-package --as team-mcp ``` +Installing team-standards... -Expected output: + Detected tools: Claude Code, Cursor + Adapting 4 practices + 1 MCP server... -``` -Installing MCP template from https://github.com/troylar/devsync-python-package... -Namespace: team-mcp + Claude Code: + Created: .claude/rules/code-style.md + Created: .claude/rules/testing.md + Created: .claude/rules/security.md + Created: .claude/rules/error-handling.md -MCP servers found: - team-mcp.github (requires: GITHUB_PERSONAL_ACCESS_TOKEN) - team-mcp.postgres (requires: DATABASE_URL) - team-mcp.filesystem (no credentials required) + Cursor: + Created: .cursor/rules/code-style.mdc + Created: .cursor/rules/testing.mdc + Created: .cursor/rules/security.mdc + Created: .cursor/rules/error-handling.mdc -3 MCP server(s) installed. + MCP server "github" requires credentials: + GITHUB_TOKEN (required): GitHub personal access token + > ghp_xxxxxxxxxxxxxxxxxxxx -Next step: Configure credentials with 'devsync mcp configure team-mcp' +Installation complete. ``` ---- - -## Step 7: Configure MCP Credentials - -MCP servers that access external services need credentials. DevSync prompts for each one interactively and stores them in a gitignored `.env` file. +## Step 5: Verify ```bash -devsync mcp configure team-mcp +devsync list ``` -Interactive prompts: - ``` -Configuring MCP servers for namespace: team-mcp - ---- team-mcp.github --- -Required: GITHUB_PERSONAL_ACCESS_TOKEN - Description: GitHub personal access token with repo scope - Enter value: **** - ---- team-mcp.postgres --- -Required: DATABASE_URL - Description: PostgreSQL connection string for the development database - Enter value: **** - ---- team-mcp.filesystem --- -No credentials required. - -Credentials saved to: /Users/you/projects/backend-api/.devsync/.env -(This file is automatically gitignored) - -3/3 servers configured. +Installed packages: + team-standards v1.0.0 4 practices, 1 MCP server Claude Code, Cursor ``` -!!! warning "Never commit credentials" - DevSync automatically adds `.devsync/.env` to `.gitignore`. Verify this by running `git status` -- the `.env` file should not appear as an untracked file. If it does, add it to `.gitignore` manually before committing anything. - -To verify credentials were saved (values are masked): +Check the installed files: ```bash -devsync mcp configure team-mcp --show-current +ls .claude/rules/ +ls .cursor/rules/ ``` -``` -Current Credentials (project scope) - -Server Variable Value Status -team-mcp.github GITHUB_PERSONAL_ACCESS_TOKEN ****abc123 configured -team-mcp.postgres DATABASE_URL ****mydb configured -team-mcp.filesystem (none required) -- ready -``` +Open your IDE -- the AI coding assistant now follows your team's standards. --- -## Step 8: Sync MCP to AI Tools +## Without AI (File-Copy Mode) -With credentials configured, sync the MCP server definitions to all detected AI tools: +If the developer doesn't have an LLM API key: ```bash -devsync mcp sync --tool all +devsync install https://github.com/company/team-standards --no-ai ``` -Expected output: - -``` -Syncing MCP servers to AI tools... -Scope: project -Tools: all - -Synced to 2 tool(s): - claude -- 3 server(s) written to claude_desktop_config.json - cursor -- 3 server(s) written to mcp_config.json - -Server Summary: - Synced: 3 server(s) - Skipped: 0 server(s) -``` - -To sync to a single tool: - -```bash -devsync mcp sync --tool claude -``` - -!!! tip "Dry run" - Preview what would be written without making changes: - - ```bash - devsync mcp sync --tool all --dry-run - ``` +Files are copied directly without AI adaptation. This still works -- the developer gets the same standards, just without intelligent merging with existing rules. --- -## Step 9: Verify Everything - -Run a series of checks to confirm the setup is complete. - -### Check installed templates - -```bash -devsync template list -``` - -Expected output: - -``` -Installed Templates: - -Namespace Name Type Scope Source -company coding-conventions instruction global github.com/troylar/devsync-starter-templates -company security-policy instruction global github.com/troylar/devsync-starter-templates -company commit-standards instruction global github.com/troylar/devsync-starter-templates -backend api-design instruction project github.com/troylar/devsync-python-package -backend database-patterns instruction project github.com/troylar/devsync-python-package -backend review-pr command project github.com/troylar/devsync-python-package -backend run-migrations command project github.com/troylar/devsync-python-package -``` - -### Check installed packages (if any) - -```bash -devsync list installed -``` - -### Check MCP server status - -```bash -devsync mcp list -``` +## Onboarding Script -### Check project file structure - -```bash -ls -la .claude/rules/ -ls -la .claude/commands/ -``` - -Expected files: - -``` -.claude/rules/ - backend.api-design.md - backend.database-patterns.md - -.claude/commands/ - backend.review-pr.md - backend.run-migrations.md -``` - -Global templates are in `~/.claude/rules/`: - -```bash -ls -la ~/.claude/rules/ -``` - -``` -~/.claude/rules/ - company.coding-conventions.md - company.security-policy.md - company.commit-standards.md -``` - ---- - -## Complete Onboarding Script - -For organizations that onboard developers frequently, combine all steps into a script: +Automate the entire process with a shell script: ```bash #!/usr/bin/env bash set -e -echo "=== DevSync Developer Onboarding ===" - -# Step 1: Install DevSync -pip install --upgrade devsync +echo "Installing DevSync..." +pip install devsync -# Step 2: Show detected tools -echo "" -echo "--- Detected AI Tools ---" +echo "Detecting AI tools..." devsync tools -# Step 3: Install global standards -echo "" -echo "--- Installing company-wide standards ---" -devsync template install https://github.com/troylar/devsync-starter-templates \ - --as company --scope global --force - -# Step 4: Project setup (run from within the project directory) -echo "" -echo "--- Installing project templates ---" -devsync template install https://github.com/troylar/devsync-python-package \ - --as backend --force - -# Step 5: Install MCP servers -echo "" -echo "--- Installing MCP servers ---" -devsync mcp install https://github.com/troylar/devsync-python-package \ - --as team-mcp --force - -# Step 6: Configure credentials (interactive) -echo "" -echo "--- Configuring MCP credentials ---" -devsync mcp configure team-mcp - -# Step 7: Sync MCP to tools -echo "" -echo "--- Syncing MCP to AI tools ---" -devsync mcp sync --tool all - -# Step 8: Verify -echo "" -echo "--- Verification ---" -devsync template list -devsync mcp list - -echo "" -echo "=== Onboarding complete ===" -``` - -Save this as `scripts/onboard.sh` in your project repository and add it to your onboarding documentation. - ---- - -## Troubleshooting +echo "Installing team standards..." +devsync install https://github.com/company/team-standards -### "No AI tools detected" +echo "Verifying installation..." +devsync list -Install at least one supported AI coding assistant. Run `devsync tools` after installation to confirm detection. - -### "Template namespace already exists" - -The developer may have a partial installation from a previous attempt. Use `--force` to overwrite: - -```bash -devsync template install --as --force -``` - -### "Missing credentials" when syncing MCP - -One or more MCP servers need credentials that have not been configured. Run: - -```bash -devsync mcp configure +echo "Done! Your AI tools are configured." ``` -This prompts for any unconfigured values. - -### Credentials file appears in `git status` - -The `.devsync/.env` file should be gitignored automatically. If it appears as untracked: - -```bash -echo ".devsync/.env" >> .gitignore -git add .gitignore -git commit -m "chore: gitignore devsync credentials" -``` - -### Templates not taking effect in AI assistant - -Some AI tools require a restart to pick up new configuration files. Close and reopen your IDE or restart the AI assistant process. - --- ## Next Steps -- [Create a Team Configuration Repository](team-config-repo.md) -- Set up and maintain your own template repository -- [Build and Distribute a Custom Package](custom-packages.md) -- Bundle instructions, MCP servers, hooks, and more -- [AI-Assisted Configuration Merging](ai-merge-workflow.md) -- Use AI to manage configuration drift +- [Team Config Repository](team-config-repo.md) -- create your own team standards +- [Multi-IDE Workflow](multi-ide-workflow.md) -- managing standards across multiple IDEs diff --git a/docs/tutorials/team-config-repo.md b/docs/tutorials/team-config-repo.md index 6403449..9c015e2 100644 --- a/docs/tutorials/team-config-repo.md +++ b/docs/tutorials/team-config-repo.md @@ -1,486 +1,135 @@ -# Create a Team Configuration Repository +# Tutorial: Create a Team Config Repository -**Time to complete**: 25 minutes -**Difficulty**: Intermediate -**Prerequisites**: +**Time:** 15 minutes | **Level:** Beginner -- DevSync installed (`pip install devsync`) -- Git and GitHub CLI (`gh`) available -- A GitHub organization or personal account for hosting - -## What You Will Build +Create a shareable package of your team's coding standards and distribute it via Git. -A Git-hosted configuration repository containing coding standards, slash commands, and hooks that your entire team can install with a single command. By the end of this tutorial, any team member can run: +--- -```bash -devsync template install https://github.com/troylar/devsync-starter-templates --as team -``` +## What You Will Learn -...and immediately have your team's conventions loaded into their AI assistant. +- Extract practices from an existing project +- Share the package as a Git repository +- Install the package on other machines and projects -## What You Will Learn +## Prerequisites -- How to scaffold a template repository with `devsync template init` -- The structure of a `templatekit.yaml` manifest -- How to write instructions, commands, and hooks -- How to publish and distribute templates via Git -- How to update templates and sync changes across the team +- DevSync installed (`pip install devsync`) +- LLM configured (`devsync setup`) +- A project with existing AI rules (`.claude/rules/`, `.cursor/rules/`, etc.) +- Git installed --- -## Step 1: Initialize the Template Repository +## Step 1: Extract Practices -Use `devsync template init` to scaffold a new template repository. This creates a directory with the correct structure and a starter manifest. +Navigate to a project that has established coding standards: ```bash -cd ~/projects -devsync template init my-team-standards +cd ~/my-team-project ``` -This creates the following directory structure: - -``` -my-team-standards/ -├── templatekit.yaml # Manifest describing all templates -├── templates/ -│ ├── rules/ # Instruction files (coding standards, guidelines) -│ │ └── example-rule.md -│ ├── commands/ # Slash commands (e.g., /review-code) -│ │ └── example-command.md -│ └── hooks/ # Hooks (pre-prompt, post-prompt automation) -│ └── example-hook.md -└── README.md -``` - -Enter the directory and inspect the generated manifest: +Run the extract command: ```bash -cd my-team-standards +devsync extract --output ./team-standards --name team-standards ``` -The generated `templatekit.yaml` looks like this: - -```yaml -name: my-team-standards -version: 1.0.0 -description: Template repository created with DevSync - -templates: - - name: example-rule - description: An example instruction rule - ide: claude - files: - - path: .claude/rules/example-rule.md - type: instruction - tags: [example] -``` - -!!! info "About the manifest" - The `templatekit.yaml` file is the source of truth for your template repository. It declares every template, its target IDE, the files it installs, and metadata like tags and descriptions. DevSync reads this file during `template install` to know what to copy and where. - ---- - -## Step 2: Add a Coding Standards Instruction - -Replace the example rule with a real coding standards instruction. Create a Python style guide that your AI assistant will follow. - -Create the file `templates/rules/python-standards.md`: - -```markdown -# Python Coding Standards - -All Python code in this project must follow these conventions. - -## Formatting and Style - -- Use **black** for formatting with a line length of 120 characters. -- Use **ruff** for linting with rule sets E, F, I, N, W. -- Use **mypy** in strict mode for type checking. - -## Type Hints - -All functions must include type hints for parameters and return values: +Expected output: -```python -def calculate_total(items: list[dict[str, float]], tax_rate: float = 0.0) -> float: - """Calculate the total price including tax.""" - subtotal = sum(item["price"] * item["quantity"] for item in items) - return subtotal * (1 + tax_rate) ``` +Extracting practices from /home/user/my-team-project... -## Naming Conventions - -- Functions and variables: `snake_case` -- Classes: `PascalCase` -- Constants: `UPPER_SNAKE_CASE` -- Private attributes: `_leading_underscore` - -## Error Handling + Found 4 rule files across 2 AI tools + Found 1 MCP server configuration -- Use specific exception types, never bare `except:` or `except Exception:`. -- Log errors with context before re-raising. -- Include relevant identifiers (user ID, request ID) in error messages. +Extracted: + Practices: 3 + MCP servers: 1 -```python -import logging - -logger = logging.getLogger(__name__) - -def fetch_user(user_id: int) -> User: - try: - return db.query(User).filter_by(id=user_id).one() - except NoResultFound: - logger.warning("User not found", extra={"user_id": user_id}) - raise UserNotFoundError(f"No user with id={user_id}") +Package written to: ./team-standards/devsync-package.yaml ``` -## Imports - -Order imports in three groups separated by blank lines: - -1. Standard library -2. Third-party packages -3. Local application modules - -```python -import os -from pathlib import Path +## Step 2: Review the Package -import requests -from pydantic import BaseModel - -from myapp.models import User -from myapp.utils import format_date -``` - -## Testing - -- Every public function must have at least one test. -- Use the Arrange-Act-Assert pattern. -- Minimum 80% test coverage for new code. -``` - -Now update `templatekit.yaml` to reference this file instead of the example: - -```yaml -name: my-team-standards -version: 1.0.0 -description: Team coding standards and workflow automation - -templates: - - name: python-standards - description: Python coding conventions (formatting, types, naming, errors) - ide: claude - files: - - path: .claude/rules/python-standards.md - type: instruction - tags: [python, style, standards] -``` - -Remove the old example file: +Check what was created: ```bash -rm templates/rules/example-rule.md +ls team-standards/ ``` ---- - -## Step 3: Add a Slash Command - -Slash commands are markdown files that define reusable prompts your AI assistant can execute. Add a `/review-code` command that performs a structured code review. - -Create the file `templates/commands/review-code.md`: - -```markdown -# /review-code - -Review the current file or selection against team standards. Check each category and report findings. - -## Checklist - -### Correctness -- Does the code do what the function/class name suggests? -- Are edge cases handled (empty inputs, None values, boundary conditions)? -- Are there off-by-one errors or incorrect boolean logic? - -### Error Handling -- Are specific exception types used (not bare `except:`)? -- Are errors logged with context before re-raising? -- Do error messages include relevant identifiers? - -### Type Safety -- Do all functions have parameter and return type hints? -- Are Optional types used where values can be None? -- Would mypy pass in strict mode? - -### Testing -- Is there a corresponding test for each public function? -- Do tests cover both success and failure paths? -- Is the Arrange-Act-Assert pattern followed? - -### Style -- Does naming follow snake_case (functions/variables) and PascalCase (classes)? -- Are imports ordered: stdlib, third-party, local? -- Is the line length within 120 characters? - -## Output Format - -For each category, report: -- **Pass**: Requirements met -- **Issue**: Describe the problem and suggest a fix -- **N/A**: Category does not apply - -Summarize with a count of issues found and their severity (critical, warning, suggestion). ``` - -Update `templatekit.yaml` to include the command: - -```yaml -name: my-team-standards -version: 1.0.0 -description: Team coding standards and workflow automation - -templates: - - name: python-standards - description: Python coding conventions (formatting, types, naming, errors) - ide: claude - files: - - path: .claude/rules/python-standards.md - type: instruction - tags: [python, style, standards] - - - name: review-code - description: Structured code review against team standards - ide: claude - files: - - path: .claude/commands/review-code.md - type: command - tags: [review, quality, workflow] +devsync-package.yaml +practices/ +mcp/ ``` -Remove the old example files: +Review the manifest: ```bash -rm templates/commands/example-command.md -rm templates/hooks/example-hook.md +cat team-standards/devsync-package.yaml ``` ---- +Verify the practice declarations capture your team's intent accurately. Edit as needed. -## Step 4: Validate the Repository - -Before publishing, validate that the manifest and file references are correct: - -```bash -devsync template validate . -``` - -Expected output: - -``` -Validating template repository... - -Templates found: 2 - python-standards (instruction) -- templates/rules/python-standards.md - review-code (command) -- templates/commands/review-code.md - -All files referenced in templatekit.yaml exist. -Validation passed. -``` - -!!! warning "Fix before publishing" - If validation reports missing files or manifest errors, fix them before pushing. Team members who install a broken repository will get confusing errors. - ---- - -## Step 5: Push to Git - -Initialize the repository, commit, and push to GitHub: +## Step 3: Create a Git Repository ```bash +cd team-standards git init git add . -git commit -m "feat: initial team standards with Python conventions and review command" - -gh repo create troylar/devsync-starter-templates --public --source=. --remote=origin --push -``` - -!!! tip "Private repositories" - For proprietary team standards, use `--private` instead of `--public`. Team members will need read access to the repository. - -Tag the first release: - -```bash -git tag -a v1.0.0 -m "Release v1.0.0: Python standards and review command" -git push origin v1.0.0 -``` - ---- - -## Step 6: Team Members Install - -Share the installation command with your team. Each member runs this in their project directory: - -```bash -cd ~/projects/my-project -devsync template install https://github.com/troylar/devsync-starter-templates --as team -``` - -Expected output: - +git commit -m "Initial team standards package" ``` -Installing templates from https://github.com/troylar/devsync-starter-templates... -Namespace: team -Installed: - team.python-standards -> .claude/rules/team.python-standards.md - team.review-code -> .claude/commands/team.review-code.md - -2 templates installed successfully. -``` - -The namespace prefix (`team.`) prevents conflicts if team members also have personal templates installed. - -### Installing with Global Scope - -For standards that should apply across all projects (not just one), use the `--scope global` flag: +Push to your Git hosting service: ```bash -devsync template install https://github.com/troylar/devsync-starter-templates --as team --scope global +git remote add origin https://github.com/your-company/team-standards +git push -u origin main ``` -This installs to `~/.claude/rules/` instead of the project-level `.claude/rules/`. +## Step 4: Team Members Install -### Verify the Installation +Each team member runs: ```bash -devsync template list -``` - -Expected output: - -``` -Installed Templates: - -Namespace Name Type Scope Source -team python-standards instruction project github.com/troylar/devsync-starter-templates -team review-code command project github.com/troylar/devsync-starter-templates -``` - ---- - -## Step 7: Update and Sync Changes - -When standards evolve, the team lead updates the repository and team members sync. - -### Team Lead: Push an Update - -Edit `templates/rules/python-standards.md` to add a new section, then bump the version: - -```yaml -# In templatekit.yaml -version: 1.1.0 +cd ~/their-project +devsync install https://github.com/your-company/team-standards ``` -Commit and push: - -```bash -git add . -git commit -m "feat: add docstring requirements to Python standards" -git tag -a v1.1.0 -m "Release v1.1.0: Added docstring requirements" -git push origin main --tags -``` +DevSync clones the repo, reads the practices, and adapts them to the team member's existing setup and detected AI tools. -### Team Members: Pull the Update +## Step 5: Update the Standards -Each team member updates their installation: +When standards change, update the package: ```bash -cd ~/projects/my-project -devsync template update team -``` - -Expected output: - +cd ~/my-team-project +# Update your rules as needed +devsync extract --output ./team-standards --name team-standards +cd team-standards +git add . && git commit -m "Update coding standards" +git push ``` -Updating namespace 'team' from https://github.com/troylar/devsync-starter-templates... -Updated: - team.python-standards (1.0.0 -> 1.1.0) - -1 template updated. -``` - -To update all installed template namespaces at once: +Team members reinstall to get updates: ```bash -devsync template update --all -``` - ---- - -## Final Repository Structure - -After completing all steps, your repository looks like this: - -``` -my-team-standards/ -├── templatekit.yaml -├── templates/ -│ ├── rules/ -│ │ └── python-standards.md -│ └── commands/ -│ └── review-code.md -└── README.md -``` - -And in each team member's project: - -``` -my-project/ -├── .claude/ -│ ├── rules/ -│ │ └── team.python-standards.md -│ └── commands/ -│ └── team.review-code.md -├── .devsync/ -│ └── installations.json -└── ... (project files) +devsync install https://github.com/your-company/team-standards --conflict overwrite ``` --- ## Troubleshooting -### "Template namespace 'team' already exists" - -This means the namespace is already installed. Use `--force` to overwrite: - -```bash -devsync template install https://github.com/troylar/devsync-starter-templates --as team --force -``` - -### Files not appearing after install - -Check that the `templatekit.yaml` `files.path` values match where your IDE looks for configuration. For Claude Code, instructions go in `.claude/rules/` and commands in `.claude/commands/`. - -```bash -ls -la .claude/rules/ -ls -la .claude/commands/ -``` +**No practices extracted?** Make sure your project has AI rule files in standard locations (`.claude/rules/`, `.cursor/rules/`, etc.). -### Team member cannot access private repository - -Ensure the team member has read access to the GitHub repository and their Git credentials are configured: - -```bash -gh auth status -``` +**AI not working?** Run `devsync setup` to configure your LLM provider. Or use `--no-ai` for file-copy mode. --- ## Next Steps -- [Onboard a New Developer](onboard-new-developer.md) -- Full onboarding walkthrough using templates and MCP -- [Build and Distribute a Custom Package](custom-packages.md) -- Create multi-component packages with MCP servers, hooks, and more -- [AI-Assisted Configuration Merging](ai-merge-workflow.md) -- Use AI to compare and merge configuration changes +- [Onboard New Developers](onboard-new-developer.md) -- use your package to onboard team members +- [Multi-IDE Workflow](multi-ide-workflow.md) -- install across multiple AI tools +- [Custom Packages](custom-packages.md) -- build packages with more component types diff --git a/mkdocs.yml b/mkdocs.yml index feb23a3..5518369 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,14 +76,12 @@ nav: - getting-started/concepts.md - CLI: - cli/index.md - - cli/download.md + - cli/setup.md + - cli/extract.md - cli/install.md - cli/list.md - - cli/update.md - - cli/delete.md - cli/uninstall.md - cli/tools.md - - cli/package.md - IDE Integrations: - ide-integrations/index.md - ide-integrations/claude-code.md @@ -98,20 +96,10 @@ nav: - packages/components.md - packages/installing.md - packages/examples.md - - MCP Server: - - mcp-server/index.md - - mcp-server/installation.md - - mcp-server/tools-reference.md - - mcp-server/team-profiles.md - - mcp-server/ai-merge-flow.md - - mcp-server/backups-and-recovery.md - - mcp-server/ide-setup.md - Tutorials: - tutorials/team-config-repo.md - tutorials/onboard-new-developer.md - - tutorials/ai-merge-workflow.md - tutorials/custom-packages.md - - tutorials/ci-cd-integration.md - tutorials/multi-ide-workflow.md - tutorials/migrate-existing-configs.md - Advanced: From 54735c6b18dfcc4d37716c379036ce80ce70abb3 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:46:36 -0500 Subject: [PATCH 13/14] fix(cli): fix 5 bugs and 2 security issues from code review (#63) - Fix temp directory leak in Git clone installs (install_v2.py) - Strip markdown fences from LLM JSON responses (extractor.py) - Read practice_name from JSON in parse_adaptation_response (response_models.py) - Apply --conflict flag in fallback install path (install_v2.py) - Return exit code 1 when v1 upgrade finds no files (extract.py) - Mask credential input with password=True + validate non-empty (mcp_credential_prompter.py) - Use Path.relative_to() instead of str.startswith() for path traversal guard (install_v2.py, extract.py) --- devsync/cli/extract.py | 8 +-- devsync/cli/install_v2.py | 67 +++++++++++++++++++------ devsync/core/extractor.py | 16 +++++- devsync/core/mcp_credential_prompter.py | 9 ++-- devsync/llm/response_models.py | 2 +- 5 files changed, 78 insertions(+), 24 deletions(-) diff --git a/devsync/cli/extract.py b/devsync/cli/extract.py index a125567..99bb1e8 100644 --- a/devsync/cli/extract.py +++ b/devsync/cli/extract.py @@ -149,7 +149,9 @@ def _upgrade_v1_package( continue for ref in refs: src_file = (package_path / ref.file).resolve() - if not str(src_file).startswith(str(package_path.resolve())): + try: + src_file.relative_to(package_path.resolve()) + except ValueError: console.print(f" [red]Rejected (path traversal): {ref.file}[/red]") continue if src_file.exists() and src_file.stat().st_size < 100_000: @@ -160,8 +162,8 @@ def _upgrade_v1_package( console.print(f" [yellow]Could not read: {ref.file}[/yellow]") if not instruction_files: - console.print("[yellow]No instruction files found in v1 package.[/yellow]") - return 0 + console.print("[red]No instruction files found in v1 package.[/red]") + return 1 llm = None if not no_ai: diff --git a/devsync/cli/install_v2.py b/devsync/cli/install_v2.py index 9c9dfd6..a9b5252 100644 --- a/devsync/cli/install_v2.py +++ b/devsync/cli/install_v2.py @@ -1,5 +1,6 @@ """V2 install command — AI-powered package installation.""" +import shutil import tempfile from pathlib import Path from typing import Optional @@ -45,28 +46,37 @@ def install_v2_command( if not project_root: project_root = project_path + cloned_tmp: Path | None = None package_path = _resolve_source(source) if not package_path: console.print(f"[red]Could not resolve source: {source}[/red]") return 1 - fmt = detect_manifest_format(package_path) - if not fmt: - console.print(f"[red]No manifest found in {package_path}[/red]") - console.print("Expected: devsync-package.yaml or ai-config-kit-package.yaml") - return 1 + # Track if we cloned a temp directory so we can clean it up + if source.startswith(("http://", "https://", "git@", "github.com")): + cloned_tmp = package_path + + try: + fmt = detect_manifest_format(package_path) + if not fmt: + console.print(f"[red]No manifest found in {package_path}[/red]") + console.print("Expected: devsync-package.yaml or ai-config-kit-package.yaml") + return 1 - manifest = parse_manifest(package_path) - target_tools = _resolve_tools(tool) + manifest = parse_manifest(package_path) + target_tools = _resolve_tools(tool) - console.print(f"\n[bold]Installing: {manifest.name} v{manifest.version}[/bold]") - console.print(f" {manifest.description}") - console.print(f" Format: {'v2 (AI-native)' if manifest.is_v2 else 'v1 (file-copy)'}") - console.print(f" Tools: {', '.join(target_tools)}") + console.print(f"\n[bold]Installing: {manifest.name} v{manifest.version}[/bold]") + console.print(f" {manifest.description}") + console.print(f" Format: {'v2 (AI-native)' if manifest.is_v2 else 'v1 (file-copy)'}") + console.print(f" Tools: {', '.join(target_tools)}") - if manifest.is_v2 and manifest.has_practices and not no_ai: - return _install_v2_ai(manifest, project_root, target_tools) - return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict) + if manifest.is_v2 and manifest.has_practices and not no_ai: + return _install_v2_ai(manifest, project_root, target_tools) + return _install_v2_fallback(manifest, package_path, project_root, target_tools, conflict) + finally: + if cloned_tmp and cloned_tmp.exists(): + shutil.rmtree(cloned_tmp, ignore_errors=True) def _resolve_source(source: str) -> Optional[Path]: @@ -153,7 +163,9 @@ def _install_v2_fallback( continue for ref in refs: src_file = (package_path / ref.file).resolve() - if not str(src_file).startswith(str(package_path.resolve())): + try: + src_file.relative_to(package_path.resolve()) + except ValueError: console.print(f" [red]Rejected (path traversal): {ref.file}[/red]") continue if not src_file.exists(): @@ -163,7 +175,30 @@ def _install_v2_fallback( content = src_file.read_text(encoding="utf-8") for tool_name in target_tools: dest = _get_tool_instruction_path(tool_name, project_root, ref.name) - if dest and not dest.exists(): + if not dest: + continue + if dest.exists(): + if conflict == "skip": + console.print(f" [dim]Skipped (exists): {ref.name} → {dest.relative_to(project_root)}[/dim]") + continue + elif conflict == "overwrite": + dest.write_text(content, encoding="utf-8") + installed_count += 1 + console.print(f" Overwritten: {ref.name} → {dest.relative_to(project_root)}") + elif conflict == "rename": + suffix = 1 + renamed = dest.with_stem(f"{dest.stem}-{suffix}") + while renamed.exists(): + suffix += 1 + renamed = dest.with_stem(f"{dest.stem}-{suffix}") + renamed.parent.mkdir(parents=True, exist_ok=True) + renamed.write_text(content, encoding="utf-8") + installed_count += 1 + console.print(f" Installed (renamed): {ref.name} → {renamed.relative_to(project_root)}") + else: + rel = dest.relative_to(project_root) + console.print(f" [yellow]Exists: {ref.name} → {rel} (skipped)[/yellow]") + else: dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(content, encoding="utf-8") installed_count += 1 diff --git a/devsync/core/extractor.py b/devsync/core/extractor.py index 8e3da42..49c3ca9 100644 --- a/devsync/core/extractor.py +++ b/devsync/core/extractor.py @@ -2,6 +2,7 @@ import json import logging +import re from pathlib import Path from typing import Optional @@ -113,7 +114,8 @@ def _extract_with_ai(self, files: dict[str, str], mcp_configs: list[dict]) -> Ex try: prompt = EXTRACT_MCP_PROMPT.format(mcp_config=json.dumps(mcp_config, indent=2)) response = self._llm.complete(prompt, system=SYSTEM_PROMPT) - data = json.loads(response.content) + content = _strip_markdown_fences(response.content) + data = json.loads(content) mcp_servers.append(MCPDeclaration.from_dict(data)) except (LLMProviderError, ValueError, json.JSONDecodeError) as e: logger.warning("MCP extraction failed for %s: %s", mcp_config.get("name", "unknown"), e) @@ -162,3 +164,15 @@ def _practices_from_files(self, files: dict[str, str]) -> list[PracticeDeclarati ) ) return practices + + +def _strip_markdown_fences(text: str) -> str: + """Strip markdown code fences from LLM response text. + + LLMs sometimes wrap JSON output in ```json ... ``` fences. + This extracts the content inside the fences if present. + """ + match = re.search(r"```(?:json)?\s*\n(.*?)\n```", text, re.DOTALL) + if match: + return match.group(1).strip() + return text.strip() diff --git a/devsync/core/mcp_credential_prompter.py b/devsync/core/mcp_credential_prompter.py index e790dce..b773a4c 100644 --- a/devsync/core/mcp_credential_prompter.py +++ b/devsync/core/mcp_credential_prompter.py @@ -65,10 +65,13 @@ def _prompt_single_credential(cred: CredentialSpec) -> str: default = cred.default or "" if cred.required: - value = Prompt.ask(f" Enter {cred.name}", default=default if default else None) - return value or "" + while True: + value = Prompt.ask(f" Enter {cred.name}", default=default if default else None, password=True) + if value and value.strip(): + return value.strip() + console.print(" [red]This credential is required. Please enter a value.[/red]") else: - value = Prompt.ask(f" Enter {cred.name}", default=default) + value = Prompt.ask(f" Enter {cred.name}", default=default, password=True) return value or "" diff --git a/devsync/llm/response_models.py b/devsync/llm/response_models.py index ac65d1c..1e6b943 100644 --- a/devsync/llm/response_models.py +++ b/devsync/llm/response_models.py @@ -156,7 +156,7 @@ def parse_adaptation_response(raw_json: str) -> AdaptationAction: return AdaptationAction( action=data.get("action", "skip"), - practice_name="", + practice_name=data.get("practice_name", ""), reason=data.get("reason", ""), file_name=data.get("file_name", ""), content=data.get("merged_content", ""), From aecfac38931072ce19ddfabd1ab5ecfa5b5a78a8 Mon Sep 17 00:00:00 2001 From: Troy Larson <1931732+troylar@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:51:06 -0500 Subject: [PATCH 14/14] fix(cli): fix Windows path test and remove stale tui import (#63) --- devsync/tui/__init__.py | 4 ---- tests/unit/cli/test_install_v2.py | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/devsync/tui/__init__.py b/devsync/tui/__init__.py index 0914f9f..b3bd1d3 100644 --- a/devsync/tui/__init__.py +++ b/devsync/tui/__init__.py @@ -1,5 +1 @@ """TUI (Text User Interface) components for DevSync.""" - -from devsync.tui.installer import show_installer_tui - -__all__ = ["show_installer_tui"] diff --git a/tests/unit/cli/test_install_v2.py b/tests/unit/cli/test_install_v2.py index f260e74..866f4e4 100644 --- a/tests/unit/cli/test_install_v2.py +++ b/tests/unit/cli/test_install_v2.py @@ -22,12 +22,12 @@ class TestGetToolInstructionPath: def test_claude_path(self, tmp_path: Path) -> None: result = _get_tool_instruction_path("claude", tmp_path, "test-rule") assert result is not None - assert str(result).endswith(".claude/rules/test-rule.md") + assert result == tmp_path / ".claude" / "rules" / "test-rule.md" def test_cursor_path(self, tmp_path: Path) -> None: result = _get_tool_instruction_path("cursor", tmp_path, "test-rule") assert result is not None - assert str(result).endswith(".cursor/rules/test-rule.mdc") + assert result == tmp_path / ".cursor" / "rules" / "test-rule.mdc" def test_unknown_tool_returns_none(self, tmp_path: Path) -> None: result = _get_tool_instruction_path("unknown-tool", tmp_path, "test")