diff --git a/.claude/settings.json b/.claude/settings.json
index d00b2b9..0e0dcd2 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,15 +1,3 @@
{
- "hooks": [
- {
- "name": "pacc-celebration-hook",
- "path": "hooks/pacc-celebration-hook.json",
- "events": [
- "*"
- ],
- "matchers": [
- "*"
- ]
- }
- ],
- "mcps": []
+
}
\ No newline at end of file
diff --git a/ai_docs/knowledge/claude-code-subagents-docs.md b/ai_docs/knowledge/claude-code-subagents-docs.md
index f6a25ce..077d3f6 100644
--- a/ai_docs/knowledge/claude-code-subagents-docs.md
+++ b/ai_docs/knowledge/claude-code-subagents-docs.md
@@ -1,4 +1,4 @@
-# Subagents
+# Subagents ([Source])(https://docs.anthropic.com/en/docs/claude-code/sub-agents.md)
> Create and use specialized AI subagents in Claude Code for task-specific workflows and improved context management.
@@ -15,60 +15,6 @@ Subagents are pre-configured AI personalities that Claude Code can delegate task
When Claude Code encounters a task that matches a subagent's expertise, it can delegate that task to the specialized subagent, which works independently and returns results.
-## Key benefits
-
-
-
- Each subagent operates in its own context, preventing pollution of the main conversation and keeping it focused on high-level objectives.
-
-
-
- Subagents can be fine-tuned with detailed instructions for specific domains, leading to higher success rates on designated tasks.
-
-
-
- Once created, subagents can be used across different projects and shared with your team for consistent workflows.
-
-
-
- Each subagent can have different tool access levels, allowing you to limit powerful tools to specific subagent types.
-
-
-
-## Quick start
-
-To create your first subagent:
-
-
-
- Run the following command:
-
- ```
- /agents
- ```
-
-
-
- Choose whether to create a project-level or user-level subagent
-
-
-
- * **Recommended**: Generate with Claude first, then customize to make it yours
- * Describe your subagent in detail and when it should be used
- * Select the tools you want to grant access to (or leave blank to inherit all tools)
- * The interface shows all available tools, making selection easy
- * If you're generating with Claude, you can also edit the system prompt in your own editor by pressing `e`
-
-
-
- Your subagent is now available! Claude will use it automatically when appropriate, or you can invoke it explicitly:
-
- ```
- > Use the code-reviewer subagent to check my recent changes
- ```
-
-
-
## Subagent configuration
### File locations
diff --git a/apps/pacc-cli/CHANGELOG.md b/apps/pacc-cli/CHANGELOG.md
index fc691ba..c67e758 100644
--- a/apps/pacc-cli/CHANGELOG.md
+++ b/apps/pacc-cli/CHANGELOG.md
@@ -61,6 +61,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated documentation with plugin user guide
- Restructured codebase to support plugin architecture
+### Fixed
+- CommandsValidator no longer incorrectly requires `name` field in frontmatter (PR #3)
+- CommandsValidator now correctly treats frontmatter as optional
+- AgentsValidator now expects `tools` as comma-separated string per Claude Code docs
+- AgentsValidator removed invalid optional fields not in Claude Code specification
+- Validators now properly warn about unknown fields instead of failing
+- `pacc info` now handles directories correctly like `pacc validate` does
+
### Security
- Comprehensive validation before any file operations
- Atomic configuration updates with rollback
diff --git a/apps/pacc-cli/docs/api_reference.md b/apps/pacc-cli/docs/api_reference.md
index 4622bf2..6515cc1 100644
--- a/apps/pacc-cli/docs/api_reference.md
+++ b/apps/pacc-cli/docs/api_reference.md
@@ -873,6 +873,333 @@ filtered_files = file_filter.filter_files(all_files)
print(f"Found {len(all_files)} files, filtered to {len(filtered_files)}")
```
+## Project Configuration (NEW in 1.0)
+
+### ExtensionSpec
+
+Data class representing extension specifications in pacc.json files.
+
+#### Constructor
+
+```python
+ExtensionSpec(
+ name: str,
+ source: str,
+ version: str,
+ description: Optional[str] = None,
+ ref: Optional[str] = None,
+ environment: Optional[str] = None,
+ dependencies: List[str] = field(default_factory=list),
+ metadata: Dict[str, Any] = field(default_factory=dict),
+ target_dir: Optional[str] = None,
+ preserve_structure: bool = False
+)
+```
+
+**Parameters:**
+- `name` (str): Extension name
+- `source` (str): Source path or URL
+- `version` (str): Extension version
+- `description` (Optional[str]): Extension description
+- `ref` (Optional[str]): Git reference for remote sources
+- `environment` (Optional[str]): Environment restriction
+- `dependencies` (List[str]): Extension dependencies
+- `metadata` (Dict[str, Any]): Additional metadata
+- `target_dir` (Optional[str]): Custom installation directory (NEW)
+- `preserve_structure` (bool): Preserve source directory structure (NEW)
+
+#### Methods
+
+##### from_dict (class method)
+
+```python
+@classmethod
+from_dict(cls, data: Dict[str, Any]) -> 'ExtensionSpec'
+```
+
+Create ExtensionSpec from dictionary data.
+
+**Parameters:**
+- `data` (Dict[str, Any]): Extension data from pacc.json
+
+**Returns:**
+- `ExtensionSpec`: Configured extension specification
+
+**Example:**
+```python
+spec = ExtensionSpec.from_dict({
+ "name": "my-extension",
+ "source": "./extensions/my-extension.md",
+ "version": "1.0.0",
+ "targetDir": "custom/tools",
+ "preserveStructure": true
+})
+```
+
+##### to_dict
+
+```python
+to_dict() -> Dict[str, Any]
+```
+
+Convert ExtensionSpec to dictionary format.
+
+**Returns:**
+- `Dict[str, Any]`: Dictionary representation
+
+### ProjectConfigManager
+
+Manages pacc.json project configuration files.
+
+#### Constructor
+
+```python
+ProjectConfigManager()
+```
+
+#### Methods
+
+##### load_project_config
+
+```python
+load_project_config(project_dir: Path) -> Optional[Dict[str, Any]]
+```
+
+Load project configuration from pacc.json file.
+
+**Parameters:**
+- `project_dir` (Path): Project directory containing pacc.json
+
+**Returns:**
+- `Optional[Dict[str, Any]]`: Loaded configuration or None if not found
+
+**Example:**
+```python
+manager = ProjectConfigManager()
+config = manager.load_project_config(Path("./my-project"))
+if config:
+ print(f"Project: {config['name']}")
+```
+
+##### validate_project_config
+
+```python
+validate_project_config(project_dir: Path) -> ConfigValidationResult
+```
+
+Validate project configuration structure and content.
+
+**Parameters:**
+- `project_dir` (Path): Project directory
+
+**Returns:**
+- `ConfigValidationResult`: Validation results
+
+## Extension Type Detection (NEW in 1.0)
+
+### ExtensionDetector
+
+Hierarchical extension type detection system.
+
+#### Methods
+
+##### detect_extension_type (static)
+
+```python
+@staticmethod
+detect_extension_type(
+ file_path: Union[str, Path],
+ project_dir: Optional[Union[str, Path]] = None
+) -> Optional[str]
+```
+
+Detect extension type using hierarchical approach:
+1. pacc.json declarations (highest priority)
+2. Directory structure (secondary signal)
+3. Content keywords (fallback only)
+
+**Parameters:**
+- `file_path` (Union[str, Path]): Path to file to analyze
+- `project_dir` (Optional[Union[str, Path]]): Project directory to search for pacc.json
+
+**Returns:**
+- `Optional[str]`: Extension type ('hooks', 'mcp', 'agents', 'commands') or None
+
+**Example:**
+```python
+from pacc.validators.utils import ExtensionDetector
+
+detector = ExtensionDetector()
+
+# With pacc.json context (recommended)
+detected_type = detector.detect_extension_type(
+ file_path="./my-extension.md",
+ project_dir="./my-project"
+)
+
+# Legacy detection (without pacc.json)
+detected_type = detector.detect_extension_type("./my-extension.md")
+
+print(f"Detected type: {detected_type}")
+```
+
+### ValidationResultFormatter
+
+Enhanced formatter for validation results with improved output.
+
+#### Methods
+
+##### format_result (static)
+
+```python
+@staticmethod
+format_result(result: ValidationResult, verbose: bool = False) -> str
+```
+
+Format single validation result with enhanced output.
+
+**Parameters:**
+- `result` (ValidationResult): Validation result to format
+- `verbose` (bool): Include detailed information
+
+**Returns:**
+- `str`: Formatted result string
+
+##### format_batch_results (static)
+
+```python
+@staticmethod
+format_batch_results(
+ results: List[ValidationResult],
+ show_summary: bool = True
+) -> str
+```
+
+Format multiple validation results with summary.
+
+**Parameters:**
+- `results` (List[ValidationResult]): List of validation results
+- `show_summary` (bool): Include summary statistics
+
+**Returns:**
+- `str`: Formatted batch results with summary
+
+**Example:**
+```python
+from pacc.validators.utils import ValidationResultFormatter
+
+formatter = ValidationResultFormatter()
+
+# Format single result
+formatted = formatter.format_result(result, verbose=True)
+print(formatted)
+
+# Format batch results
+batch_formatted = formatter.format_batch_results(results, show_summary=True)
+print(batch_formatted)
+```
+
+## Enhanced Validation Functions (NEW in 1.0)
+
+### validate_extension_file
+
+```python
+def validate_extension_file(
+ file_path: Union[str, Path],
+ extension_type: Optional[str] = None
+) -> ValidationResult
+```
+
+Validate single extension file with enhanced validation.
+
+**Parameters:**
+- `file_path` (Union[str, Path]): Path to extension file
+- `extension_type` (Optional[str]): Override type detection
+
+**Returns:**
+- `ValidationResult`: Enhanced validation result
+
+**Example:**
+```python
+from pacc.validators.utils import validate_extension_file
+
+# Auto-detect type
+result = validate_extension_file("./my-hook.json")
+
+# Override type detection
+result = validate_extension_file("./my-hook.json", extension_type="hooks")
+
+if result.is_valid:
+ print("✓ Extension is valid")
+else:
+ print(f"✗ Validation failed with {len(result.errors)} errors")
+```
+
+### validate_extension_directory
+
+```python
+def validate_extension_directory(
+ directory_path: Union[str, Path],
+ extension_type: Optional[str] = None
+) -> Dict[str, List[ValidationResult]]
+```
+
+Validate all extensions in directory with type grouping.
+
+**Parameters:**
+- `directory_path` (Union[str, Path]): Directory to validate
+- `extension_type` (Optional[str]): Filter by specific type
+
+**Returns:**
+- `Dict[str, List[ValidationResult]]`: Results grouped by extension type
+
+**Example:**
+```python
+from pacc.validators.utils import validate_extension_directory
+
+# Validate all types
+results = validate_extension_directory("./extensions")
+for ext_type, type_results in results.items():
+ print(f"{ext_type}: {len(type_results)} extensions")
+
+# Validate specific type only
+command_results = validate_extension_directory("./extensions", "commands")
+```
+
+## CLI Command API (NEW in 1.0)
+
+### ValidateCommand
+
+Enhanced validation command implementation.
+
+#### Arguments
+
+- `source` (str): Path to file or directory to validate
+- `--type` / `-t` (str): Override extension type detection
+- `--strict` (bool): Enable strict mode (treat warnings as errors)
+
+#### Exit Codes
+
+- `0`: Validation passed
+- `1`: Validation failed (errors found)
+- `1`: Strict mode and warnings found
+
+#### Example Usage
+
+```bash
+# Auto-detect and validate
+pacc validate ./extension.json
+
+# Override type detection
+pacc validate ./extension.json --type hooks
+
+# Strict validation
+pacc validate ./extensions/ --strict
+
+# Directory validation
+pacc validate ./project/extensions/
+```
+
## Extension Points
### Creating Custom Validators
@@ -954,8 +1281,8 @@ custom_filter = (CustomFileFilter()
---
-**Document Version**: 1.0
-**Last Updated**: 2024-08-12
-**API Compatibility**: PACC v0.1.0+
+**Document Version**: 1.1
+**Last Updated**: 2024-08-27
+**API Compatibility**: PACC v1.0.0+
For questions about the API or suggestions for improvements, please open an issue in the PACC repository.
\ No newline at end of file
diff --git a/apps/pacc-cli/docs/extension_detection_guide.md b/apps/pacc-cli/docs/extension_detection_guide.md
new file mode 100644
index 0000000..72b4dfd
--- /dev/null
+++ b/apps/pacc-cli/docs/extension_detection_guide.md
@@ -0,0 +1,471 @@
+# Extension Type Detection Guide
+
+## Overview
+
+PACC uses a hierarchical detection system to identify extension types automatically. This guide explains the detection priorities, common scenarios, and how to troubleshoot type detection issues. Understanding this system helps prevent misclassification and ensures extensions are handled correctly.
+
+## Detection Hierarchy
+
+PACC follows a three-tier detection approach:
+
+1. **pacc.json declarations** (Highest Priority)
+2. **Directory structure** (Secondary Signal)
+3. **Content keywords** (Fallback Only)
+
+### Priority 1: pacc.json Declarations
+
+When a `pacc.json` file exists, explicit declarations take absolute priority over all other detection methods.
+
+```json
+{
+ "name": "my-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "helper",
+ "source": "./agents/helper.md", // File is in agents/ directory
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+**Result:** `./agents/helper.md` is detected as a **command**, not an agent, because pacc.json declares it as such.
+
+### Priority 2: Directory Structure
+
+When no pacc.json declaration exists, directory structure provides type hints:
+
+```
+project/
+├── hooks/ # Files here detected as hooks
+│ └── pre-commit.json
+├── commands/ # Files here detected as commands
+│ └── deploy.md
+├── agents/ # Files here detected as agents
+│ └── helper.md
+└── mcp/ # Files here detected as mcp
+ └── server.json
+```
+
+### Priority 3: Content Keywords
+
+As a final fallback, PACC analyzes file content for type-specific keywords:
+
+- **Hooks:** `"hooks"`, `"event"`, `"PreToolUse"`, `"PostToolUse"`
+- **MCP:** `"mcpServers"`, `"command"`, `"args"`, `"env"`
+- **Agents:** `"tools"`, `"permissions"`, YAML frontmatter
+- **Commands:** `"#/"`, `"## Usage"`, `"## Parameters"`
+
+## Detection Examples
+
+### Example 1: pacc.json Override
+
+**File:** `./agents/slash-command.md`
+```markdown
+---
+name: deployment-helper
+description: Helps with deployments using agent-like assistance
+---
+
+# Deployment Helper
+
+This tool provides agent capabilities for deployment tasks.
+Uses tools and permissions like agents do.
+```
+
+**pacc.json:**
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "name": "deployment-helper",
+ "source": "./agents/slash-command.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+**Detection Result:** `commands` (pacc.json overrides directory structure and content)
+
+### Example 2: Directory Structure Detection
+
+**File:** `./commands/helper-tool.md`
+```markdown
+This file helps users with agent-like functionality.
+Contains keywords: tool, permission, agent assistance.
+```
+
+**No pacc.json exists**
+
+**Detection Result:** `commands` (directory structure overrides content keywords)
+
+### Example 3: Content Keyword Fallback
+
+**File:** `./random-location/clear-agent.md`
+```markdown
+---
+name: file-organizer
+description: Organizes files based on patterns
+tools: ["file-reader", "file-writer"]
+permissions: ["read-files", "write-files"]
+---
+
+# File Organizer Agent
+
+This agent analyzes files and organizes them...
+```
+
+**No pacc.json, no special directory structure**
+
+**Detection Result:** `agents` (content keywords used as fallback)
+
+## Common Detection Issues
+
+### Issue 1: Slash Commands Detected as Agents (PACC-18)
+
+**Problem:**
+```markdown
+---
+name: pacc-install
+description: Install extensions using PACC CLI tool
+---
+
+# /pacc:install
+
+Install Claude Code extensions with tool validation and permission checking.
+
+Contains keywords: tool, permission, agent-like assistance
+```
+
+**Without proper hierarchy, this could be detected as an agent.**
+
+**Solution - Use Directory Structure:**
+```
+commands/
+└── pacc-install.md # Directory structure ensures correct detection
+```
+
+**Or Use pacc.json:**
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "name": "pacc-install",
+ "source": "./pacc-install.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+### Issue 2: Ambiguous Content
+
+**Problem:**
+```markdown
+This file contains both agent and command keywords.
+Has agent, tool, permission references.
+But also has command, usage, slash patterns.
+```
+
+**Solution - Explicit Declaration:**
+```json
+{
+ "extensions": {
+ "agents": [
+ {
+ "name": "ambiguous-file",
+ "source": "./ambiguous-file.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+### Issue 3: Non-Standard File Names
+
+**Problem:**
+```
+custom-automation.json # Could be hook, but unusual name
+my-special-server.conf # Could be MCP, but unusual extension
+```
+
+**Solution - Force Type Detection:**
+```bash
+# Override detection with explicit type
+pacc validate ./custom-automation.json --type hooks
+pacc validate ./my-special-server.conf --type mcp
+```
+
+## Detection Algorithms
+
+### Hooks Detection
+
+**File Extensions:** `.json`
+
+**Directory Indicators:** `hooks/`, `automation/`
+
+**Content Keywords:**
+- `"hooks"` array present
+- `"event"` field with valid event types
+- `"PreToolUse"`, `"PostToolUse"`, etc.
+
+**Example Pattern:**
+```json
+{
+ "name": "example-hook",
+ "hooks": [ // ← Key indicator
+ {
+ "event": "PreToolUse", // ← Event type
+ "command": "npm test"
+ }
+ ]
+}
+```
+
+### MCP Server Detection
+
+**File Extensions:** `.json`
+
+**Directory Indicators:** `mcp/`, `servers/`
+
+**Content Keywords:**
+- `"mcpServers"` object present
+- Server configuration with `"command"` and `"args"`
+
+**Example Pattern:**
+```json
+{
+ "mcpServers": { // ← Key indicator
+ "database": {
+ "command": "npx", // ← Command field
+ "args": ["@mcp/server"] // ← Args array
+ }
+ }
+}
+```
+
+### Agents Detection
+
+**File Extensions:** `.md`
+
+**Directory Indicators:** `agents/`, `assistants/`
+
+**Content Keywords:**
+- YAML frontmatter with `tools` or `permissions`
+- `"tools"` array
+- `"permissions"` array
+
+**Example Pattern:**
+```markdown
+---
+name: example-agent
+tools: ["file-reader"] // ← Tools array
+permissions: ["read-files"] // ← Permissions array
+---
+```
+
+### Commands Detection
+
+**File Extensions:** `.md`
+
+**Directory Indicators:** `commands/`, `slash-commands/`
+
+**Content Keywords:**
+- Headers starting with `#/` (slash command syntax)
+- `## Usage` or `## Parameters` sections
+- Command documentation patterns
+
+**Example Pattern:**
+```markdown
+# /example // ← Slash command header
+
+## Usage // ← Usage section
+/example
+
+## Parameters // ← Parameters section
+```
+
+## Best Practices
+
+### 1. Use Explicit Declarations
+
+For production projects, always use pacc.json declarations:
+
+```json
+{
+ "name": "production-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {"name": "deploy", "source": "./deploy.md", "version": "1.0.0"}
+ ],
+ "hooks": [
+ {"name": "ci-hook", "source": "./ci.json", "version": "1.0.0"}
+ ]
+ }
+}
+```
+
+### 2. Organize by Directory Structure
+
+Structure projects to support automatic detection:
+
+```
+project/
+├── pacc.json # Explicit declarations
+├── hooks/ # Auto-detected as hooks
+├── commands/ # Auto-detected as commands
+├── agents/ # Auto-detected as agents
+└── mcp/ # Auto-detected as mcp
+```
+
+### 3. Use Consistent Naming
+
+Follow naming conventions that aid detection:
+
+```
+hooks/
+├── pre-commit-hook.json # Clear hook naming
+└── post-deploy-hook.json
+
+commands/
+├── build-command.md # Clear command naming
+└── deploy-command.md
+
+agents/
+├── file-agent.md # Clear agent naming
+└── helper-agent.md
+```
+
+### 4. Validate Detection Results
+
+Always validate that detection works correctly:
+
+```bash
+# Test detection without installation
+pacc validate ./my-extension.md
+
+# Check detected type
+pacc validate ./my-extension.md --type commands # Override if needed
+```
+
+## Troubleshooting Detection
+
+### Debug Detection Issues
+
+```bash
+# Check what type PACC detects
+pacc validate ./extension.md
+
+# Override detection for testing
+pacc validate ./extension.md --type hooks
+
+# Validate entire directory structure
+pacc validate ./project/ --strict
+```
+
+### Common Solutions
+
+1. **Wrong Type Detected**
+ - Add explicit pacc.json declaration
+ - Move file to appropriate directory
+ - Use `--type` flag to override
+
+2. **No Type Detected**
+ - Add content keywords
+ - Use standard file extensions (.json, .md)
+ - Place in conventional directory structure
+
+3. **Ambiguous Detection**
+ - Create pacc.json with explicit declarations
+ - Remove conflicting keywords from content
+ - Use directory structure as tiebreaker
+
+## Detection API
+
+### Programmatic Detection
+
+```python
+from pacc.validators.utils import ExtensionDetector
+
+detector = ExtensionDetector()
+
+# Detect with all hierarchy levels
+detected_type = detector.detect_extension_type(
+ file_path="./my-extension.md",
+ project_dir="./my-project" # Look for pacc.json here
+)
+
+# Detect without pacc.json (legacy mode)
+detected_type = detector.detect_extension_type("./my-extension.md")
+```
+
+### Detection Results
+
+```python
+# Possible return values:
+# - "hooks"
+# - "mcp"
+# - "agents"
+# - "commands"
+# - None (if no type detected)
+```
+
+## Migration from Pre-1.0
+
+### Old Detection (Pre-1.0)
+
+- Relied primarily on file content
+- Directory structure had limited influence
+- No pacc.json support
+- Prone to misclassification
+
+### New Detection (1.0+)
+
+- Hierarchical approach with clear priorities
+- pacc.json declarations take precedence
+- Directory structure as strong signal
+- Content keywords only as fallback
+
+### Migration Steps
+
+1. **Test Current Detection:**
+ ```bash
+ pacc validate ./extensions/
+ ```
+
+2. **Create pacc.json for Clarity:**
+ ```json
+ {
+ "name": "migrated-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {"name": "cmd1", "source": "./cmd1.md", "version": "1.0.0"}
+ ]
+ }
+ }
+ ```
+
+3. **Reorganize Directory Structure:**
+ ```bash
+ mkdir -p hooks commands agents mcp
+ mv *.json hooks/
+ mv *.md commands/
+ ```
+
+## Related Documentation
+
+- [Validation Guide](./validation_guide.md) - Test detection results
+- [Folder Structure Guide](./folder_structure_guide.md) - Directory organization
+- [Migration Guide](./migration_guide.md) - Upgrade from pre-1.0
+- [API Reference](./api_reference.md) - Detection API details
\ No newline at end of file
diff --git a/apps/pacc-cli/docs/folder_structure_guide.md b/apps/pacc-cli/docs/folder_structure_guide.md
new file mode 100644
index 0000000..f15335d
--- /dev/null
+++ b/apps/pacc-cli/docs/folder_structure_guide.md
@@ -0,0 +1,514 @@
+# PACC Folder Structure Configuration Guide
+
+## Overview
+
+PACC 1.0 introduces advanced folder structure configuration through `targetDir` and `preserveStructure` options in `pacc.json`. This guide explains how to customize extension installation paths, preserve directory hierarchies, and migrate from default installations.
+
+## Configuration Options
+
+### targetDir
+
+Specifies a custom installation directory for extensions within the Claude Code configuration folder.
+
+```json
+{
+ "name": "custom-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "deploy",
+ "source": "./commands/deploy.md",
+ "version": "1.0.0",
+ "targetDir": "deployment-tools"
+ }
+ ]
+ }
+}
+```
+
+**Installation Path:** `.claude/commands/deployment-tools/deploy.md`
+
+### preserveStructure
+
+Controls whether the source directory structure is preserved during installation.
+
+```json
+{
+ "name": "structured-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "build-tools",
+ "source": "./src/commands/build/",
+ "version": "1.0.0",
+ "preserveStructure": true
+ }
+ ]
+ }
+}
+```
+
+**Source Structure:**
+```
+src/commands/build/
+├── docker/
+│ ├── build.md
+│ └── deploy.md
+└── npm/
+ ├── install.md
+ └── test.md
+```
+
+**Installation Result:**
+```
+.claude/commands/build-tools/
+├── docker/
+│ ├── build.md
+│ └── deploy.md
+└── npm/
+ ├── install.md
+ └── test.md
+```
+
+## Configuration Scenarios
+
+### 1. Custom Organization
+
+```json
+{
+ "name": "organized-workspace",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "frontend-tools",
+ "source": "./commands/frontend/",
+ "version": "1.0.0",
+ "targetDir": "development/frontend"
+ },
+ {
+ "name": "backend-tools",
+ "source": "./commands/backend/",
+ "version": "1.0.0",
+ "targetDir": "development/backend"
+ }
+ ],
+ "hooks": [
+ {
+ "name": "ci-hooks",
+ "source": "./automation/ci-cd/",
+ "version": "1.0.0",
+ "targetDir": "automation",
+ "preserveStructure": true
+ }
+ ]
+ }
+}
+```
+
+**Result:**
+```
+.claude/
+├── commands/
+│ └── development/
+│ ├── frontend/
+│ │ └── [frontend tools]
+│ └── backend/
+│ └── [backend tools]
+└── hooks/
+ └── automation/
+ └── [CI/CD structure preserved]
+```
+
+### 2. Team-Based Organization
+
+```json
+{
+ "name": "team-extensions",
+ "version": "1.0.0",
+ "extensions": {
+ "agents": [
+ {
+ "name": "devops-agent",
+ "source": "./agents/devops.md",
+ "version": "1.0.0",
+ "targetDir": "teams/devops"
+ },
+ {
+ "name": "qa-agent",
+ "source": "./agents/qa.md",
+ "version": "1.0.0",
+ "targetDir": "teams/qa"
+ }
+ ]
+ }
+}
+```
+
+### 3. Environment-Specific Configuration
+
+```json
+{
+ "name": "multi-env-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "staging-commands",
+ "source": "./commands/staging/",
+ "version": "1.0.0",
+ "targetDir": "environments/staging",
+ "preserveStructure": true,
+ "environment": "staging"
+ },
+ {
+ "name": "production-commands",
+ "source": "./commands/production/",
+ "version": "1.0.0",
+ "targetDir": "environments/production",
+ "preserveStructure": true,
+ "environment": "production"
+ }
+ ]
+ }
+}
+```
+
+## Installation Behavior
+
+### Default Installation (No Configuration)
+
+```bash
+# Without targetDir or preserveStructure
+pacc install ./my-command.md --project
+```
+
+**Result:** `.claude/commands/my-command.md`
+
+### Custom Target Directory
+
+```json
+{
+ "targetDir": "custom-tools"
+}
+```
+
+**Result:** `.claude/commands/custom-tools/my-command.md`
+
+### Preserved Structure
+
+```json
+{
+ "preserveStructure": true
+}
+```
+
+**Source:** `./commands/utils/helper.md`
+**Result:** `.claude/commands/utils/helper.md`
+
+### Combined Configuration
+
+```json
+{
+ "targetDir": "tools",
+ "preserveStructure": true
+}
+```
+
+**Source:** `./src/commands/build/docker.md`
+**Result:** `.claude/commands/tools/src/commands/build/docker.md`
+
+## Migration from Default Installations
+
+### Pre-1.0 Installation Structure
+
+Before PACC 1.0, extensions were installed directly:
+
+```
+.claude/
+├── commands/
+│ ├── deploy.md
+│ ├── build.md
+│ └── test.md
+├── hooks/
+│ ├── pre-commit.json
+│ └── post-deploy.json
+└── agents/
+ └── helper.md
+```
+
+### Migration Steps
+
+1. **Assess Current Structure**
+ ```bash
+ # List current extensions
+ pacc list --all
+
+ # Check installation paths
+ ls -la .claude/commands/
+ ls -la .claude/hooks/
+ ```
+
+2. **Create Migration Configuration**
+ ```json
+ {
+ "name": "migrated-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "deploy",
+ "source": ".claude/commands/deploy.md",
+ "version": "1.0.0",
+ "targetDir": "legacy"
+ }
+ ]
+ }
+ }
+ ```
+
+3. **Backup Current Extensions**
+ ```bash
+ # Create backup
+ cp -r .claude .claude.backup
+ ```
+
+4. **Reinstall with New Structure**
+ ```bash
+ # Remove old installations
+ pacc remove deploy build test --force
+
+ # Install with new configuration
+ pacc sync
+ ```
+
+### Migration Example
+
+**Before (Pre-1.0):**
+```
+.claude/commands/
+├── deploy.md
+├── build.md
+└── test.md
+```
+
+**After (1.0 with targetDir):**
+```
+.claude/commands/
+└── project-tools/
+ ├── deploy.md
+ ├── build.md
+ └── test.md
+```
+
+**Migration Configuration:**
+```json
+{
+ "name": "project-migration",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "project-commands",
+ "source": "./commands/",
+ "version": "1.0.0",
+ "targetDir": "project-tools",
+ "preserveStructure": false
+ }
+ ]
+ }
+}
+```
+
+## Best Practices
+
+### 1. Consistent Naming Conventions
+
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "targetDir": "project-tools", // kebab-case
+ "name": "deployment-commands" // consistent naming
+ }
+ ]
+ }
+}
+```
+
+### 2. Logical Grouping
+
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "targetDir": "development",
+ "name": "dev-tools"
+ },
+ {
+ "targetDir": "deployment",
+ "name": "deploy-tools"
+ },
+ {
+ "targetDir": "testing",
+ "name": "test-tools"
+ }
+ ]
+ }
+}
+```
+
+### 3. Preserve Structure for Complex Extensions
+
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "name": "multi-component-tool",
+ "source": "./complex-commands/",
+ "preserveStructure": true, // Keep internal organization
+ "targetDir": "tools/complex" // But organize at top level
+ }
+ ]
+ }
+}
+```
+
+### 4. Environment-Specific Organization
+
+```json
+{
+ "extensions": {
+ "hooks": [
+ {
+ "name": "dev-hooks",
+ "targetDir": "environments/development",
+ "environment": "development"
+ },
+ {
+ "name": "prod-hooks",
+ "targetDir": "environments/production",
+ "environment": "production"
+ }
+ ]
+ }
+}
+```
+
+## Security Considerations
+
+### Path Validation
+
+PACC validates `targetDir` paths to prevent security issues:
+
+```json
+{
+ "targetDir": "../../../etc" // ❌ REJECTED - Path traversal
+}
+```
+
+```json
+{
+ "targetDir": "tools/secure" // ✅ ACCEPTED - Safe relative path
+}
+```
+
+### Allowed Patterns
+
+- ✅ `"tools"`
+- ✅ `"project/commands"`
+- ✅ `"teams/devops"`
+- ❌ `"../outside"`
+- ❌ `"/absolute/path"`
+- ❌ `"~/home/path"`
+
+### Claude Code Directory Boundaries
+
+`targetDir` is restricted to within the Claude Code configuration directory:
+
+```
+.claude/ # Configuration root
+├── commands/
+│ └── [targetDir]/ # Custom directories allowed here
+├── hooks/
+│ └── [targetDir]/ # Custom directories allowed here
+└── agents/
+ └── [targetDir]/ # Custom directories allowed here
+```
+
+## Command Line Interface
+
+### Installation with Structure
+
+```bash
+# Install with default structure
+pacc install ./commands/ --project
+
+# Install preserving source structure
+pacc install ./commands/ --project --preserve-structure
+
+# Install to custom directory
+pacc install ./commands/ --project --target-dir "tools"
+```
+
+### Validation
+
+```bash
+# Validate folder structure configuration
+pacc validate ./pacc.json
+
+# Check for path traversal issues
+pacc validate ./pacc.json --strict
+```
+
+## Troubleshooting
+
+### Common Issues
+
+1. **Path Not Found**
+ ```bash
+ Error: Target directory 'nonexistent/path' could not be created
+ ```
+ **Solution:** Ensure parent directories exist or use valid paths
+
+2. **Permission Denied**
+ ```bash
+ Error: Permission denied creating directory '.claude/commands/tools'
+ ```
+ **Solution:** Check file permissions for `.claude` directory
+
+3. **Path Traversal Blocked**
+ ```bash
+ Error: Invalid targetDir '../outside' - path traversal not allowed
+ ```
+ **Solution:** Use relative paths within Claude Code directory
+
+### Debug Information
+
+```bash
+# Show installation paths
+pacc list --verbose
+
+# Validate configuration
+pacc validate ./pacc.json --strict
+
+# Check directory structure
+pacc info
+```
+
+## Related Commands
+
+- [`pacc sync`](./usage_documentation.md#sync-command) - Apply folder structure configuration
+- [`pacc install`](./usage_documentation.md#install-command) - Install with custom structure
+- [`pacc validate`](./validation_guide.md) - Validate configuration
+
+## See Also
+
+- [Extension Type Detection Guide](./extension_detection_guide.md)
+- [Migration Guide](./migration_guide.md)
+- [Project Configuration Reference](./api_reference.md#project-configuration)
\ No newline at end of file
diff --git a/apps/pacc-cli/docs/pacc_1.0_migration_guide.md b/apps/pacc-cli/docs/pacc_1.0_migration_guide.md
new file mode 100644
index 0000000..fc5c4b2
--- /dev/null
+++ b/apps/pacc-cli/docs/pacc_1.0_migration_guide.md
@@ -0,0 +1,551 @@
+# PACC 1.0 Migration Guide
+
+## Overview
+
+PACC 1.0 introduces significant improvements to extension management, validation, and project configuration. This guide helps existing users migrate from pre-1.0 versions to take advantage of new features while maintaining compatibility with existing workflows.
+
+## What's New in PACC 1.0
+
+### Major Features
+
+1. **Enhanced Validation System**
+ - Improved validation command with detailed output
+ - Strict mode for production environments
+ - Better error reporting and suggestions
+
+2. **Folder Structure Configuration**
+ - Custom installation directories via `targetDir`
+ - Structure preservation with `preserveStructure`
+ - Organized extension management
+
+3. **Hierarchical Type Detection**
+ - pacc.json declarations take highest priority
+ - Directory structure as secondary signal
+ - Content keywords as fallback only
+ - Fixes slash command misclassification (PACC-18)
+
+4. **Project Configuration (`pacc.json`)**
+ - Explicit extension declarations
+ - Version locking and dependency management
+ - Team collaboration features
+
+### Breaking Changes
+
+1. **Type Detection Hierarchy**
+ - pacc.json declarations now override all other detection methods
+ - Directory structure has increased importance
+ - Content-only detection is now fallback only
+
+2. **Validation Command Improvements**
+ - Enhanced output format
+ - New `--strict` mode
+ - Better error categorization
+
+3. **Configuration File Structure**
+ - New `pacc.json` format
+ - Extended `ExtensionSpec` with folder options
+
+## Migration Steps
+
+### Step 1: Assessment
+
+First, assess your current PACC installation and extensions:
+
+```bash
+# Check current PACC version
+pacc --version
+
+# List all installed extensions
+pacc list --all
+
+# Check validation status
+pacc validate .claude/
+```
+
+### Step 2: Backup Current Configuration
+
+Create a backup of your existing configuration:
+
+```bash
+# Backup Claude Code directory
+cp -r ~/.claude ~/.claude.backup
+
+# For project-level installations
+cp -r ./.claude ./.claude.backup
+```
+
+### Step 3: Install PACC 1.0
+
+```bash
+# Upgrade PACC
+pip install --upgrade pacc-cli
+
+# Verify installation
+pacc --version # Should show 1.0.0+
+```
+
+### Step 4: Create pacc.json Configuration
+
+Generate a `pacc.json` file for explicit extension management:
+
+```json
+{
+ "name": "my-project",
+ "version": "1.0.0",
+ "description": "Migrated from pre-1.0 PACC configuration",
+ "extensions": {
+ "commands": [
+ {
+ "name": "deploy",
+ "source": ".claude/commands/deploy.md",
+ "version": "1.0.0"
+ }
+ ],
+ "hooks": [
+ {
+ "name": "pre-commit",
+ "source": ".claude/hooks/pre-commit.json",
+ "version": "1.0.0"
+ }
+ ],
+ "agents": [
+ {
+ "name": "helper",
+ "source": ".claude/agents/helper.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+### Step 5: Validate Migration
+
+```bash
+# Validate new configuration
+pacc validate ./pacc.json
+
+# Test extension detection
+pacc validate .claude/ --strict
+
+# Verify all extensions are recognized
+pacc list --all
+```
+
+## Specific Migration Scenarios
+
+### Scenario 1: Basic Extension Collection
+
+**Pre-1.0 Structure:**
+```
+.claude/
+├── commands/
+│ ├── deploy.md
+│ └── build.md
+├── hooks/
+│ └── pre-commit.json
+└── agents/
+ └── helper.md
+```
+
+**Migration Actions:**
+1. Extensions already in correct directories ✅
+2. Create `pacc.json` to declare extensions explicitly
+3. No file moves required
+
+**New pacc.json:**
+```json
+{
+ "name": "basic-extensions",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {"name": "deploy", "source": ".claude/commands/deploy.md", "version": "1.0.0"},
+ {"name": "build", "source": ".claude/commands/build.md", "version": "1.0.0"}
+ ],
+ "hooks": [
+ {"name": "pre-commit", "source": ".claude/hooks/pre-commit.json", "version": "1.0.0"}
+ ],
+ "agents": [
+ {"name": "helper", "source": ".claude/agents/helper.md", "version": "1.0.0"}
+ ]
+ }
+}
+```
+
+### Scenario 2: Mixed Location Extensions
+
+**Pre-1.0 Structure:**
+```
+project/
+├── my-commands/
+│ ├── deploy.md # Command in non-standard location
+│ └── build.md # Command in non-standard location
+├── automation/
+│ └── hooks.json # Hook in non-standard location
+└── .claude/
+ └── agents/
+ └── helper.md # Agent in standard location
+```
+
+**Migration Actions:**
+
+**Option A: Move to Standard Locations**
+```bash
+# Move to standard directories
+mkdir -p .claude/commands .claude/hooks
+mv my-commands/*.md .claude/commands/
+mv automation/hooks.json .claude/hooks/
+```
+
+**Option B: Use pacc.json Declarations (Recommended)**
+```json
+{
+ "name": "mixed-location-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {"name": "deploy", "source": "./my-commands/deploy.md", "version": "1.0.0"},
+ {"name": "build", "source": "./my-commands/build.md", "version": "1.0.0"}
+ ],
+ "hooks": [
+ {"name": "automation-hooks", "source": "./automation/hooks.json", "version": "1.0.0"}
+ ],
+ "agents": [
+ {"name": "helper", "source": ".claude/agents/helper.md", "version": "1.0.0"}
+ ]
+ }
+}
+```
+
+### Scenario 3: Misclassified Extensions (PACC-18 Fix)
+
+**Pre-1.0 Issue:**
+Slash commands were sometimes detected as agents due to content keywords.
+
+**File:** `helper.md`
+```markdown
+---
+name: deployment-helper
+description: Helps with deployments using tool integration
+---
+
+# /deploy
+
+Deploy applications with tool validation and permission checking.
+```
+
+**Pre-1.0 Detection:** `agents` (incorrect due to tool/permission keywords)
+
+**Migration Solution:**
+```json
+{
+ "extensions": {
+ "commands": [
+ {
+ "name": "deployment-helper",
+ "source": "./helper.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+}
+```
+
+**1.0 Detection:** `commands` (correct via pacc.json declaration)
+
+### Scenario 4: Custom Organization Migration
+
+Migrate to organized folder structure using new 1.0 features:
+
+**Pre-1.0 Structure:**
+```
+.claude/
+├── commands/
+│ ├── frontend-deploy.md
+│ ├── backend-deploy.md
+│ ├── frontend-build.md
+│ └── backend-build.md
+```
+
+**1.0 Migration with Custom Organization:**
+```json
+{
+ "name": "organized-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "frontend-tools",
+ "source": ".claude/commands/frontend-*.md",
+ "version": "1.0.0",
+ "targetDir": "frontend",
+ "preserveStructure": true
+ },
+ {
+ "name": "backend-tools",
+ "source": ".claude/commands/backend-*.md",
+ "version": "1.0.0",
+ "targetDir": "backend",
+ "preserveStructure": true
+ }
+ ]
+ }
+}
+```
+
+**Result Structure:**
+```
+.claude/commands/
+├── frontend/
+│ ├── frontend-deploy.md
+│ └── frontend-build.md
+└── backend/
+ ├── backend-deploy.md
+ └── backend-build.md
+```
+
+## Configuration Changes
+
+### ExtensionSpec Updates
+
+New fields available in `pacc.json`:
+
+```json
+{
+ "name": "extension-name",
+ "source": "./path/to/extension",
+ "version": "1.0.0",
+ "description": "Optional description", // NEW
+ "ref": "main", // NEW - Git reference
+ "environment": "production", // NEW - Environment restriction
+ "dependencies": ["other-extension"], // NEW - Dependencies
+ "metadata": {"key": "value"}, // NEW - Custom metadata
+ "targetDir": "custom/path", // NEW - Custom installation directory
+ "preserveStructure": true // NEW - Preserve source structure
+}
+```
+
+### Validation Enhancements
+
+New validation options:
+
+```bash
+# Pre-1.0 validation
+pacc validate ./extension.json
+
+# 1.0 enhanced validation
+pacc validate ./extension.json --strict # Treat warnings as errors
+pacc validate ./directory/ --type commands # Override type detection
+```
+
+## Team Collaboration Improvements
+
+### Version Locking
+
+```json
+{
+ "name": "team-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "shared-deploy",
+ "source": "https://github.com/team/extensions.git",
+ "version": "2.1.0", // Lock specific version
+ "ref": "v2.1.0" // Lock Git reference
+ }
+ ]
+ }
+}
+```
+
+### Environment-Specific Extensions
+
+```json
+{
+ "extensions": {
+ "hooks": [
+ {
+ "name": "dev-hooks",
+ "source": "./dev-hooks.json",
+ "version": "1.0.0",
+ "environment": "development" // Only install in dev
+ },
+ {
+ "name": "prod-hooks",
+ "source": "./prod-hooks.json",
+ "version": "1.0.0",
+ "environment": "production" // Only install in prod
+ }
+ ]
+ }
+}
+```
+
+## Common Migration Issues
+
+### Issue 1: Type Detection Changes
+
+**Problem:** Extensions detected differently than pre-1.0
+
+**Solution:** Create explicit pacc.json declarations
+```json
+{
+ "extensions": {
+ "commands": [
+ {"name": "my-extension", "source": "./my-extension.md", "version": "1.0.0"}
+ ]
+ }
+}
+```
+
+### Issue 2: Validation Failures
+
+**Problem:** New strict validation reveals issues
+
+**Solution:** Fix validation errors or use non-strict mode initially
+```bash
+# Identify issues
+pacc validate ./ --strict
+
+# Fix issues, then use strict mode
+pacc validate ./ --strict
+```
+
+### Issue 3: Path Resolution Changes
+
+**Problem:** Relative paths resolve differently
+
+**Solution:** Use absolute paths or project-relative paths
+```json
+{
+ "extensions": {
+ "commands": [
+ {"name": "cmd", "source": "./commands/cmd.md", "version": "1.0.0"}
+ ]
+ }
+}
+```
+
+## Testing Migration
+
+### Validation Checklist
+
+1. **Extension Detection**
+ ```bash
+ pacc validate ./pacc.json
+ pacc validate .claude/ --strict
+ ```
+
+2. **Installation Testing**
+ ```bash
+ # Test in clean environment
+ cp -r .claude .claude.test
+ rm -rf .claude
+ pacc sync # Reinstall from pacc.json
+ ```
+
+3. **Functionality Testing**
+ - Test each extension works correctly
+ - Verify Claude Code integration
+ - Check command execution
+
+### Rollback Plan
+
+If migration issues occur:
+
+```bash
+# Restore backup
+rm -rf .claude
+mv .claude.backup .claude
+
+# Reinstall pre-1.0 version if needed
+pip install pacc-cli==0.9.0 # Replace with last working version
+```
+
+## Best Practices Post-Migration
+
+### 1. Use pacc.json for All Projects
+
+```json
+{
+ "name": "project-name",
+ "version": "1.0.0",
+ "extensions": {
+ // Explicit declarations
+ }
+}
+```
+
+### 2. Organize Extensions by Type
+
+```
+project/
+├── pacc.json
+├── extensions/
+│ ├── hooks/
+│ ├── commands/
+│ ├── agents/
+│ └── mcp/
+└── .claude/ # Installation target
+```
+
+### 3. Use Strict Validation
+
+```bash
+# In CI/CD pipelines
+pacc validate ./ --strict
+
+# Before deployment
+pacc validate ./pacc.json --strict
+```
+
+### 4. Version Control pacc.json
+
+```gitignore
+# .gitignore
+.claude/ # Don't commit installed extensions
+!pacc.json # Do commit configuration
+```
+
+## Getting Help
+
+### Documentation
+
+- [Validation Guide](./validation_guide.md)
+- [Folder Structure Guide](./folder_structure_guide.md)
+- [Extension Detection Guide](./extension_detection_guide.md)
+- [API Reference](./api_reference.md)
+
+### Troubleshooting
+
+```bash
+# Check PACC version
+pacc --version
+
+# Validate configuration
+pacc validate ./pacc.json --strict
+
+# Debug extension detection
+pacc validate ./extension.md --type commands
+```
+
+### Community Support
+
+- GitHub Issues: Report bugs and migration problems
+- Documentation: Comprehensive guides and examples
+- Examples: Sample configurations and migrations
+
+## Migration Success Criteria
+
+Your migration is successful when:
+
+1. ✅ All extensions validate without errors
+2. ✅ Extensions are detected correctly
+3. ✅ pacc.json configuration is working
+4. ✅ Team members can sync successfully
+5. ✅ CI/CD pipelines pass validation
+6. ✅ Claude Code integration works as expected
+
+Congratulations on successfully migrating to PACC 1.0! 🎉
\ No newline at end of file
diff --git a/apps/pacc-cli/docs/validation_guide.md b/apps/pacc-cli/docs/validation_guide.md
new file mode 100644
index 0000000..7206314
--- /dev/null
+++ b/apps/pacc-cli/docs/validation_guide.md
@@ -0,0 +1,439 @@
+# PACC Validation Guide
+
+## Overview
+
+The `pacc validate` command provides comprehensive validation of Claude Code extensions without installing them. This guide covers all validation features, use cases, and best practices for ensuring extension quality before deployment.
+
+## Command Syntax
+
+```bash
+pacc validate [options]
+```
+
+### Parameters
+
+- **``**: Path to extension file or directory to validate
+- **`[options]`**: Optional flags to modify validation behavior
+
+### Options
+
+| Option | Short | Description |
+|--------|-------|-------------|
+| `--type` | `-t` | Specify extension type (`hooks`, `mcp`, `agents`, `commands`) |
+| `--strict` | | Enable strict validation (treat warnings as errors) |
+
+## Basic Usage
+
+### Validate Single File
+
+```bash
+# Auto-detect extension type and validate
+pacc validate ./my-hook.json
+
+# Specify type explicitly
+pacc validate ./my-hook.json --type hooks
+
+# Example output:
+# ✓ VALID: ./my-hook.json
+# Type: hooks
+#
+# Validation Summary:
+# Valid: 1/1
+# Errors: 0
+# Warnings: 0
+```
+
+### Validate Directory
+
+```bash
+# Validate all extensions in directory
+pacc validate ./extensions/
+
+# Validate specific type in directory
+pacc validate ./extensions/ --type commands
+
+# Example output:
+# ✓ VALID: ./extensions/deploy.md
+# Type: commands
+#
+# ✓ VALID: ./extensions/build.md
+# Type: commands
+#
+# ✗ INVALID: ./extensions/broken.md
+# Type: commands
+#
+# Errors (2):
+# • MISSING_TITLE: Command file must have a title starting with '#'
+# • INVALID_SYNTAX: Invalid markdown syntax at line 15
+#
+# Validation Summary:
+# Valid: 2/3
+# Errors: 2
+# Warnings: 0
+```
+
+## Extension Type-Specific Validation
+
+### Hooks Validation
+
+```bash
+# Validate hook configuration
+pacc validate ./pre-commit-hook.json --type hooks
+
+# Common validations:
+# - JSON structure correctness
+# - Required fields (name, description, hooks)
+# - Valid event types (PreToolUse, PostToolUse, etc.)
+# - Command safety and security
+# - Environment variable usage
+```
+
+**Example Hook File:**
+```json
+{
+ "name": "pre-commit-hook",
+ "description": "Runs checks before tool execution",
+ "hooks": [
+ {
+ "event": "PreToolUse",
+ "command": "npm run lint"
+ }
+ ]
+}
+```
+
+### MCP Server Validation
+
+```bash
+# Validate MCP server configuration
+pacc validate ./mcp-server.json --type mcp
+
+# Common validations:
+# - Server configuration structure
+# - Executable path verification
+# - Environment variables
+# - Port and connection settings
+# - Security constraints
+```
+
+**Example MCP Server File:**
+```json
+{
+ "mcpServers": {
+ "database": {
+ "command": "npx",
+ "args": ["@modelcontextprotocol/server-postgres"],
+ "env": {
+ "DATABASE_URL": "postgresql://localhost/mydb"
+ }
+ }
+ }
+}
+```
+
+### Agents Validation
+
+```bash
+# Validate agent configuration
+pacc validate ./my-agent.md --type agents
+
+# Common validations:
+# - YAML frontmatter structure
+# - Required metadata (name, description)
+# - Tool declarations
+# - Permission specifications
+# - Content format and completeness
+```
+
+**Example Agent File:**
+```markdown
+---
+name: file-organizer
+description: Organizes files based on content and patterns
+tools: ["file-reader", "file-writer"]
+permissions: ["read-files", "write-files"]
+---
+
+# File Organizer Agent
+
+This agent helps organize files by analyzing their content...
+```
+
+### Commands Validation
+
+```bash
+# Validate slash command
+pacc validate ./deploy-command.md --type commands
+
+# Common validations:
+# - Markdown structure
+# - Required title format
+# - Usage documentation
+# - Parameter descriptions
+# - Example completeness
+```
+
+**Example Command File:**
+```markdown
+# /deploy
+
+Deploy application to production environment.
+
+## Usage
+/deploy [--dry-run]
+
+## Parameters
+- `target`: Deployment target (staging, production)
+- `--dry-run`: Preview changes without executing
+
+## Examples
+/deploy production
+/deploy staging --dry-run
+```
+
+## Advanced Validation Features
+
+### Strict Mode
+
+Enable strict validation to treat warnings as errors:
+
+```bash
+# Strict validation - warnings will cause failure
+pacc validate ./extensions/ --strict
+
+# Use case: CI/CD pipelines where high quality is required
+# Example in GitHub Actions:
+# - name: Validate Extensions
+# run: pacc validate ./src/extensions/ --strict
+```
+
+### Type Detection Override
+
+Force validation with specific type when auto-detection fails:
+
+```bash
+# Force validation as hooks even if file doesn't match patterns
+pacc validate ./custom-automation.json --type hooks
+
+# Useful for:
+# - Non-standard file names
+# - Custom extension formats
+# - Development and testing
+```
+
+## Common Validation Scenarios
+
+### Development Workflow
+
+```bash
+# 1. Validate during development
+pacc validate ./my-extension.json
+# Fix any errors reported
+
+# 2. Validate before committing
+pacc validate ./src/extensions/ --strict
+# Ensure all extensions pass strict validation
+
+# 3. Validate in CI pipeline
+pacc validate ./extensions/ --strict
+# Automated quality gates
+```
+
+### Team Collaboration
+
+```bash
+# Validate shared extension repository
+git clone https://github.com/team/extensions.git
+cd extensions
+pacc validate ./ --strict
+
+# Validate specific developer's extensions
+pacc validate ./contributors/john/ --type commands
+
+# Validate before merging PR
+pacc validate ./src/ --strict
+```
+
+### Directory Structure Validation
+
+```bash
+# Validate extensions with proper directory structure
+my-project/
+├── hooks/
+│ ├── pre-commit.json # Auto-detected as hooks
+│ └── post-deploy.json # Auto-detected as hooks
+├── commands/
+│ ├── deploy.md # Auto-detected as commands
+│ └── build.md # Auto-detected as commands
+└── agents/
+ └── helper.md # Auto-detected as agents
+
+# Validate entire project
+pacc validate ./my-project/
+```
+
+## Error Handling and Troubleshooting
+
+### Common Error Types
+
+| Error Code | Description | Solution |
+|------------|-------------|----------|
+| `INVALID_JSON` | Malformed JSON syntax | Use JSON validator to fix syntax |
+| `MISSING_FIELD` | Required field missing | Add missing field to configuration |
+| `INVALID_EVENT` | Unknown hook event type | Use valid event type (PreToolUse, PostToolUse) |
+| `UNSAFE_COMMAND` | Potentially dangerous command | Review command for security issues |
+| `FILE_NOT_FOUND` | Referenced file missing | Ensure file exists at specified path |
+| `INVALID_MARKDOWN` | Malformed markdown | Fix markdown syntax errors |
+
+### Validation Failures
+
+```bash
+# Example validation failure
+pacc validate ./broken-hook.json
+
+# Output:
+# ✗ INVALID: ./broken-hook.json
+# Type: hooks
+#
+# Errors (3):
+# • INVALID_JSON: Unexpected token at line 15, column 4
+# • MISSING_FIELD: Required field 'description' is missing
+# • UNSAFE_COMMAND: Command 'rm -rf /' contains dangerous operations
+#
+# Warnings (1):
+# • DEPRECATED_FIELD: Field 'version' is deprecated, use 'schemaVersion'
+```
+
+### Debug Information
+
+Use verbose output for detailed information:
+
+```bash
+# Enable verbose output (when available)
+pacc validate ./extension.json -v
+
+# Check specific validation rules
+pacc validate ./extension.json --type hooks
+```
+
+## Integration Examples
+
+### GitHub Actions
+
+```yaml
+name: Validate Extensions
+on: [push, pull_request]
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: '3.9'
+ - name: Install PACC
+ run: pip install pacc-cli
+ - name: Validate Extensions
+ run: pacc validate ./src/extensions/ --strict
+```
+
+### Pre-commit Hook
+
+```bash
+#!/bin/bash
+# .git/hooks/pre-commit
+
+echo "Validating extensions..."
+if ! pacc validate ./src/extensions/ --strict; then
+ echo "Extension validation failed. Commit aborted."
+ exit 1
+fi
+echo "Extensions validated successfully."
+```
+
+### Makefile Integration
+
+```makefile
+.PHONY: validate test
+
+validate:
+ pacc validate ./src/extensions/ --strict
+
+test: validate
+ pytest tests/
+
+ci: validate test
+ echo "All checks passed"
+```
+
+## Best Practices
+
+### 1. Regular Validation
+
+- Validate extensions during development
+- Run validation before committing changes
+- Include validation in CI/CD pipelines
+
+### 2. Use Strict Mode in Production
+
+```bash
+# Development - allow warnings
+pacc validate ./extensions/
+
+# Production/CI - no warnings allowed
+pacc validate ./extensions/ --strict
+```
+
+### 3. Type-Specific Directories
+
+Organize extensions by type for automatic detection:
+
+```
+project/
+├── hooks/ # Auto-detected as hooks
+├── mcp/ # Auto-detected as mcp
+├── agents/ # Auto-detected as agents
+└── commands/ # Auto-detected as commands
+```
+
+### 4. Validation in Development Workflow
+
+```bash
+# Before starting work
+pacc validate ./my-extension.json
+
+# After making changes
+pacc validate ./my-extension.json --strict
+
+# Before pushing
+pacc validate ./src/extensions/ --strict
+```
+
+### 5. Team Standards
+
+- Establish validation requirements for your team
+- Use strict mode for shared extensions
+- Document validation requirements in README
+- Include validation in code review process
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| `0` | All validations passed |
+| `1` | Validation errors found |
+| `1` | Strict mode enabled and warnings found |
+
+## Related Commands
+
+- [`pacc install`](./usage_documentation.md#install-command) - Install validated extensions
+- [`pacc list`](./usage_documentation.md#list-command) - List installed extensions
+- [`pacc info`](./usage_documentation.md#info-command) - View extension details
+
+## See Also
+
+- [Extension Type Detection Guide](./extension_detection_guide.md)
+- [Folder Structure Configuration](./folder_structure_guide.md)
+- [Migration Guide](./migration_guide.md)
+- [API Reference](./api_reference.md)
\ No newline at end of file
diff --git a/apps/pacc-cli/pacc/__init__.py b/apps/pacc-cli/pacc/__init__.py
index 74b4f72..8ba4619 100644
--- a/apps/pacc-cli/pacc/__init__.py
+++ b/apps/pacc-cli/pacc/__init__.py
@@ -1,3 +1,3 @@
"""PACC - Package manager for Claude Code."""
-__version__ = "0.2.0"
\ No newline at end of file
+__version__ = "1.0.0"
diff --git a/apps/pacc-cli/pacc/cli.py b/apps/pacc-cli/pacc/cli.py
index ea8b47b..c34671e 100644
--- a/apps/pacc-cli/pacc/cli.py
+++ b/apps/pacc-cli/pacc/cli.py
@@ -1495,7 +1495,12 @@ def validate_command(self, args) -> int:
result = validate_extension_file(source_path, args.type)
results = [result] if result else []
else:
- results = validate_extension_directory(source_path, args.type)
+ # validate_extension_directory returns Dict[str, List[ValidationResult]]
+ # Flatten it into a single list for CLI processing
+ validation_dict = validate_extension_directory(source_path, args.type)
+ results = []
+ for extension_type, validation_results in validation_dict.items():
+ results.extend(validation_results)
if not results:
self._print_error("No valid extensions found to validate")
@@ -1503,7 +1508,7 @@ def validate_command(self, args) -> int:
# Format and display results
formatter = ValidationResultFormatter()
- output = formatter.format_batch_results(results, show_summary=True)
+ output = formatter.format_batch_results(results, show_summary=True, verbose=args.verbose)
print(output)
# Check for errors
@@ -2024,8 +2029,13 @@ def info_command(self, args) -> int:
source_path = Path(source)
if source_path.exists():
- # Source is a file path - validate and extract info
- return self._handle_info_for_file(source_path, args)
+ # Check if it's a directory or file
+ if source_path.is_dir():
+ # Handle directory - find extension files inside
+ return self._handle_info_for_directory(source_path, args)
+ else:
+ # Source is a file path - validate and extract info
+ return self._handle_info_for_file(source_path, args)
elif source_path.is_absolute() or "/" in source or "\\" in source:
# Source looks like a file path but doesn't exist
self._print_error(f"File does not exist: {source_path}")
@@ -2078,6 +2088,37 @@ def _handle_info_for_file(self, file_path: Path, args) -> int:
else:
return self._display_info_formatted(extension_info, args)
+ def _handle_info_for_directory(self, directory_path: Path, args) -> int:
+ """Handle info command for directory containing extensions."""
+ from .validators import validate_extension_directory
+
+ # Find all extension files in the directory
+ validation_dict = validate_extension_directory(directory_path, args.type)
+
+ # Flatten results
+ all_files = []
+ for extension_type, validation_results in validation_dict.items():
+ for result in validation_results:
+ all_files.append(result)
+
+ if not all_files:
+ self._print_error(f"No extension files found in: {directory_path}")
+ return 1
+
+ if len(all_files) == 1:
+ # Single file found - show info for it
+ file_path = Path(all_files[0].file_path)
+ return self._handle_info_for_file(file_path, args)
+ else:
+ # Multiple files found - show summary or prompt
+ self._print_info(f"Found {len(all_files)} extension files in {directory_path}:")
+ for result in all_files:
+ file_path = Path(result.file_path)
+ status = "✓" if result.is_valid else "✗"
+ self._print_info(f" {status} {file_path.relative_to(directory_path.parent)}")
+ self._print_info("\nSpecify a single file to see detailed info.")
+ return 0
+
def _handle_info_for_installed(self, extension_name: str, args) -> int:
"""Handle info command for installed extension name."""
config_manager = ClaudeConfigManager()
diff --git a/apps/pacc-cli/pacc/core/project_config.py b/apps/pacc-cli/pacc/core/project_config.py
index 0058efd..fdbab4e 100644
--- a/apps/pacc-cli/pacc/core/project_config.py
+++ b/apps/pacc-cli/pacc/core/project_config.py
@@ -53,6 +53,9 @@ class ExtensionSpec:
environment: Optional[str] = None # Environment restriction
dependencies: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
+ # Folder structure specification (PACC-19, PACC-25)
+ target_dir: Optional[str] = None # Custom installation directory
+ preserve_structure: bool = False # Whether to preserve source directory structure
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ExtensionSpec':
@@ -70,7 +73,10 @@ def from_dict(cls, data: Dict[str, Any]) -> 'ExtensionSpec':
ref=data.get('ref'),
environment=data.get('environment'),
dependencies=data.get('dependencies', []),
- metadata=data.get('metadata', {})
+ metadata=data.get('metadata', {}),
+ # Folder structure specification - support both camelCase and snake_case
+ target_dir=data.get('targetDir') if 'targetDir' in data else data.get('target_dir'),
+ preserve_structure=data.get('preserveStructure', data.get('preserve_structure', False))
)
def to_dict(self) -> Dict[str, Any]:
@@ -91,6 +97,11 @@ def to_dict(self) -> Dict[str, Any]:
result['dependencies'] = self.dependencies
if self.metadata:
result['metadata'] = self.metadata
+ # Folder structure specification - use camelCase for JSON compatibility
+ if self.target_dir:
+ result['targetDir'] = self.target_dir
+ if self.preserve_structure:
+ result['preserveStructure'] = self.preserve_structure
return result
@@ -462,6 +473,44 @@ def _validate_extension_spec(self, ext_spec: Dict[str, Any], ext_type: str, inde
f"Invalid extension version format: {version}",
context
)
+
+ # Validate folder structure specification fields (PACC-19, PACC-25)
+ # targetDir validation - check both possible field names
+ target_dir = ext_spec.get('targetDir')
+ if target_dir is None:
+ target_dir = ext_spec.get('target_dir')
+
+ if target_dir is not None:
+ if not isinstance(target_dir, str):
+ result.add_error(
+ "INVALID_TARGET_DIR",
+ "targetDir must be a string",
+ context
+ )
+ elif not target_dir.strip():
+ result.add_error(
+ "INVALID_TARGET_DIR",
+ "targetDir must be a non-empty string",
+ context
+ )
+ elif '..' in target_dir or target_dir.startswith('/'):
+ result.add_error(
+ "UNSAFE_TARGET_DIR",
+ "targetDir cannot contain '..' or start with '/' for security reasons",
+ context
+ )
+
+ # preserveStructure validation - check both possible field names
+ preserve_structure = ext_spec.get('preserveStructure')
+ if preserve_structure is None:
+ preserve_structure = ext_spec.get('preserve_structure')
+
+ if preserve_structure is not None and not isinstance(preserve_structure, bool):
+ result.add_error(
+ "INVALID_PRESERVE_STRUCTURE",
+ "preserveStructure must be a boolean value",
+ context
+ )
def _validate_plugins_structure(self, config: Dict[str, Any], result: ConfigValidationResult):
"""Validate plugins structure for team collaboration."""
@@ -1815,6 +1864,140 @@ def install_extension(self, ext_spec: ExtensionSpec, ext_type: str, project_dir:
# Exception classes
+class InstallationPathResolver:
+ """Resolves installation paths with folder structure specification support."""
+
+ def __init__(self):
+ self.path_normalizer = PathNormalizer()
+ self.file_validator = FilePathValidator(allowed_extensions={'.json', '.yaml', '.yml', '.md'})
+
+ def resolve_target_path(
+ self,
+ extension_spec: ExtensionSpec,
+ base_install_dir: Path,
+ source_file_path: Optional[Path] = None
+ ) -> Path:
+ """
+ Resolve the target installation path for an extension file.
+
+ Args:
+ extension_spec: Extension specification with folder structure settings
+ base_install_dir: Base Claude Code installation directory
+ source_file_path: Path to the source file being installed (for structure preservation)
+
+ Returns:
+ Resolved target installation path
+ """
+ # Start with base installation directory
+ target_base = base_install_dir
+
+ # Apply custom target directory if specified
+ if extension_spec.target_dir:
+ # Validate target directory for security
+ target_dir = self._validate_target_directory(extension_spec.target_dir)
+ target_base = base_install_dir / target_dir
+
+ # Handle structure preservation
+ if extension_spec.preserve_structure and source_file_path:
+ return self._resolve_with_structure_preservation(
+ extension_spec, target_base, source_file_path
+ )
+ else:
+ return self._resolve_without_structure_preservation(
+ extension_spec, target_base, source_file_path
+ )
+
+ def _validate_target_directory(self, target_dir: str) -> str:
+ """Validate target directory for security and normalize path."""
+ # Prevent path traversal attacks
+ if '..' in target_dir or target_dir.startswith('/'):
+ raise ValidationError(f"Invalid target directory: {target_dir}. Relative paths with '..' or absolute paths are not allowed.")
+
+ # Basic normalization - remove trailing slashes and handle empty parts
+ normalized = target_dir.strip().rstrip('/')
+ if not normalized:
+ raise ValidationError("Target directory cannot be empty")
+
+ # Convert to Path for additional validation without resolving
+ path_obj = Path(normalized)
+
+ # Ensure it's a relative path
+ if path_obj.is_absolute():
+ raise ValidationError(f"Target directory must be relative: {target_dir}")
+
+ return normalized
+
+ def _resolve_with_structure_preservation(
+ self,
+ extension_spec: ExtensionSpec,
+ target_base: Path,
+ source_file_path: Path
+ ) -> Path:
+ """Resolve path preserving source directory structure."""
+ if not source_file_path:
+ return target_base
+
+ # Extract relative path from source
+ if extension_spec.source.startswith('./') or extension_spec.source.startswith('../'):
+ # Local source - preserve relative structure
+ source_base = Path(extension_spec.source).parent
+ if source_base != Path('.'):
+ # Add source directory structure to target
+ relative_structure = source_file_path.relative_to(source_base) if source_base in source_file_path.parents else source_file_path.name
+ return target_base / relative_structure
+
+ # For remote sources or when structure can't be determined, use filename only
+ return target_base / source_file_path.name
+
+ def _resolve_without_structure_preservation(
+ self,
+ extension_spec: ExtensionSpec,
+ target_base: Path,
+ source_file_path: Optional[Path]
+ ) -> Path:
+ """Resolve path without preserving source structure (flat installation)."""
+ if source_file_path:
+ return target_base / source_file_path.name
+ else:
+ # For directory sources, return the base target
+ return target_base
+
+ def get_extension_install_directory(self, extension_type: str, claude_code_dir: Path) -> Path:
+ """Get the base installation directory for an extension type."""
+ type_directories = {
+ 'hooks': claude_code_dir / 'hooks',
+ 'mcps': claude_code_dir / 'mcps',
+ 'agents': claude_code_dir / 'agents',
+ 'commands': claude_code_dir / 'commands'
+ }
+
+ if extension_type not in type_directories:
+ raise ValueError(f"Unknown extension type: {extension_type}")
+
+ return type_directories[extension_type]
+
+ def create_target_directory(self, target_path: Path) -> None:
+ """Create target directory structure if it doesn't exist."""
+ target_dir = target_path.parent
+ try:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ logger.debug(f"Created target directory: {target_dir}")
+ except OSError as e:
+ raise ValidationError(f"Failed to create target directory {target_dir}: {e}")
+
+ def validate_target_path(self, target_path: Path, claude_code_dir: Path) -> bool:
+ """Validate that target path is within Claude Code directory bounds."""
+ try:
+ # Resolve both paths to handle symlinks and relative components
+ resolved_target = target_path.resolve()
+ resolved_claude_dir = claude_code_dir.resolve()
+
+ # Check if target is within Claude Code directory
+ return resolved_claude_dir in resolved_target.parents or resolved_target == resolved_claude_dir
+ except (OSError, ValueError):
+ return False
+
+
class ProjectConfigError(PACCError):
"""Base exception for project configuration errors."""
pass
\ No newline at end of file
diff --git a/apps/pacc-cli/pacc/plugins/converter.py b/apps/pacc-cli/pacc/plugins/converter.py
index ab8af9d..7342f51 100644
--- a/apps/pacc-cli/pacc/plugins/converter.py
+++ b/apps/pacc-cli/pacc/plugins/converter.py
@@ -107,21 +107,131 @@ def scan_extensions(self, source_directory: Union[str, Path]) -> List[ExtensionI
logger.warning(f"Source directory does not exist: {source_path}")
return []
- # Look for .claude directory
- claude_dir = source_path / ".claude"
- if not claude_dir.exists():
- logger.debug(f"No .claude directory found in {source_path}")
+ extensions = []
+
+ # First, check if this is a .claude directory itself
+ if source_path.name == ".claude" or (source_path / "hooks").exists() or (source_path / "agents").exists() or (source_path / "commands").exists() or (source_path / "mcp").exists():
+ # Scan directly from this directory
+ extensions.extend(self._scan_hooks(source_path))
+ extensions.extend(self._scan_agents(source_path))
+ extensions.extend(self._scan_commands(source_path))
+ extensions.extend(self._scan_mcp(source_path))
+ else:
+ # Look for .claude directory
+ claude_dir = source_path / ".claude"
+ if claude_dir.exists():
+ extensions.extend(self._scan_hooks(claude_dir))
+ extensions.extend(self._scan_agents(claude_dir))
+ extensions.extend(self._scan_commands(claude_dir))
+ extensions.extend(self._scan_mcp(claude_dir))
+ else:
+ # Check if source_path itself contains extension directories
+ logger.debug(f"No .claude directory found in {source_path}, checking for direct extension directories")
+ extensions.extend(self._scan_hooks(source_path))
+ extensions.extend(self._scan_agents(source_path))
+ extensions.extend(self._scan_commands(source_path))
+ extensions.extend(self._scan_mcp(source_path))
+
+ logger.info(f"Found {len(extensions)} extensions in {source_path}")
+ return extensions
+
+ def scan_single_file(self, file_path: Union[str, Path]) -> List[ExtensionInfo]:
+ """Scan a single extension file.
+
+ Args:
+ file_path: Path to the extension file
+
+ Returns:
+ List containing the extension info for the file
+ """
+ file_path = Path(file_path)
+
+ if not file_path.exists():
+ logger.warning(f"File does not exist: {file_path}")
+ return []
+
+ if not file_path.is_file():
+ logger.warning(f"Path is not a file: {file_path}")
return []
extensions = []
- # Scan for different extension types
- extensions.extend(self._scan_hooks(claude_dir))
- extensions.extend(self._scan_agents(claude_dir))
- extensions.extend(self._scan_commands(claude_dir))
- extensions.extend(self._scan_mcp(claude_dir))
+ # Detect extension type based on file path and extension
+ extension_type = None
+ validator = None
+
+ # Check file extension and path components
+ if file_path.suffix == ".json":
+ # Could be hooks or MCP
+ if "hooks" in file_path.parts or "hook" in file_path.stem.lower():
+ extension_type = "hooks"
+ validator = self.hooks_validator
+ elif "mcp" in file_path.parts or "server" in file_path.stem.lower():
+ extension_type = "mcp"
+ validator = self.mcp_validator
+ else:
+ # Try both validators to see which one works
+ try:
+ result = self.hooks_validator.validate_single(file_path)
+ if result.is_valid:
+ extension_type = "hooks"
+ validator = self.hooks_validator
+ except:
+ pass
+
+ if not extension_type:
+ try:
+ result = self.mcp_validator.validate_single(file_path)
+ if result.is_valid:
+ extension_type = "mcp"
+ validator = self.mcp_validator
+ except:
+ pass
+ elif file_path.suffix == ".md":
+ # Could be agent or command
+ if "agent" in file_path.parts or "agent" in file_path.stem.lower():
+ extension_type = "agents"
+ validator = self.agents_validator
+ elif "command" in file_path.parts or "cmd" in file_path.stem.lower():
+ extension_type = "commands"
+ validator = self.commands_validator
+ else:
+ # Try both validators to see which one works
+ try:
+ result = self.agents_validator.validate_single(file_path)
+ if result.is_valid:
+ extension_type = "agents"
+ validator = self.agents_validator
+ except:
+ pass
+
+ if not extension_type:
+ try:
+ result = self.commands_validator.validate_single(file_path)
+ if result.is_valid:
+ extension_type = "commands"
+ validator = self.commands_validator
+ except:
+ pass
+
+ if extension_type and validator:
+ try:
+ validation_result = validator.validate_single(file_path)
+ ext_info = ExtensionInfo(
+ path=file_path,
+ extension_type=extension_type,
+ name=file_path.stem,
+ metadata=validation_result.metadata,
+ validation_errors=validation_result.errors,
+ is_valid=validation_result.is_valid
+ )
+ extensions.append(ext_info)
+ logger.info(f"Detected {extension_type} extension: {file_path.name}")
+ except Exception as e:
+ logger.warning(f"Failed to validate file {file_path}: {e}")
+ else:
+ logger.warning(f"Could not detect extension type for file: {file_path}")
- logger.info(f"Found {len(extensions)} extensions in {source_path}")
return extensions
def convert_to_plugin(
@@ -668,26 +778,31 @@ def convert_extension(
metadata: Optional[PluginMetadata] = None,
overwrite: bool = False
) -> ConversionResult:
- """Convert single extension."""
- # Use the main converter
- extensions = self.converter.scan_extensions(source_path.parent)
+ """Convert single extension or directory."""
+ extensions = []
+
+ # Check if source_path is a file or directory
+ if source_path.is_file():
+ # Handle single file conversion
+ extensions = self.converter.scan_single_file(source_path)
+ else:
+ # Handle directory conversion
+ extensions = self.converter.scan_extensions(source_path)
+
if not extensions:
result = ConversionResult(success=False)
result.errors.append("No extensions found")
return result
- # Filter for this specific extension
- target_extensions = [ext for ext in extensions if ext.path == source_path]
- if not target_extensions:
- result = ConversionResult(success=False)
- result.errors.append("Specified extension not found")
- return result
-
if not plugin_name:
- plugin_name = target_extensions[0].name
+ # Auto-generate plugin name
+ if source_path.is_file():
+ plugin_name = source_path.stem
+ else:
+ plugin_name = source_path.name if source_path.name != ".claude" else source_path.parent.name
return self.converter.convert_to_plugin(
- extensions=target_extensions,
+ extensions=extensions,
plugin_name=plugin_name,
destination=self.output_dir,
author_name=metadata.author if metadata else None,
diff --git a/apps/pacc-cli/pacc/validators/README.md b/apps/pacc-cli/pacc/validators/README.md
index dbcf30f..f897541 100644
--- a/apps/pacc-cli/pacc/validators/README.md
+++ b/apps/pacc-cli/pacc/validators/README.md
@@ -115,13 +115,13 @@ Validates Model Context Protocol server configurations:
- Timeouts must be positive numbers
#### `AgentsValidator`
-Validates AI agent definition files:
+Validates AI agent definition files per Claude Code documentation:
**Supported Features:**
- YAML frontmatter parsing
- Required field validation (name, description)
-- Tool and permission validation
-- Parameter schema validation
+- Tools validation as comma-separated string
+- Unknown field warnings
- Markdown content analysis
**Example Agent:**
@@ -129,13 +129,7 @@ Validates AI agent definition files:
---
name: code-reviewer
description: Reviews code for best practices
-tools: [file_reader, analyzer]
-permissions: [read_files]
-parameters:
- language:
- type: choice
- choices: [python, javascript]
- required: true
+tools: Read, Grep, Glob, Bash
---
# Code Reviewer Agent
@@ -145,32 +139,25 @@ This agent specializes in code review...
**Validation Rules:**
- YAML frontmatter required with name and description
-- Valid permission types enforced
-- Parameter types must be supported
-- Temperature between 0 and 1
-- Semantic versioning for version field
+- Tools must be comma-separated string (e.g., "Read, Write, Bash")
+- Unknown fields generate warnings (not errors)
+- Tools field is optional (inherits all if omitted)
#### `CommandsValidator`
-Validates slash command definition files:
+Validates slash command definition files per Claude Code documentation:
**Supported Features:**
-- YAML frontmatter or simple markdown formats
-- Command name validation
-- Parameter schema validation
-- Usage pattern validation
-- Alias and permission validation
+- YAML frontmatter is completely optional
+- Command name derived from filename (not frontmatter)
+- Valid frontmatter fields: allowed-tools, argument-hint, description, model
+- Simple markdown format fully supported
-**Example Command:**
+**Example Command (with optional frontmatter):**
```markdown
---
-name: deploy
description: Deploy to specified environment
-usage: /deploy [environment]
-parameters:
- environment:
- type: choice
- choices: [dev, staging, prod]
- required: true
+allowed-tools: Bash(git:*), Bash(npm:*)
+argument-hint: [environment]
---
# Deploy Command
@@ -179,11 +166,10 @@ Deploy your application...
```
**Validation Rules:**
-- Command names must start with letter
-- Reserved names (help, exit, etc.) forbidden
-- Parameter types validated
-- Choice parameters require choices array
-- Usage patterns should start with /
+- Frontmatter is completely optional
+- No required fields (name comes from filename)
+- Unknown frontmatter fields generate warnings
+- Both YAML frontmatter and simple markdown supported
## Utility Classes
diff --git a/apps/pacc-cli/pacc/validators/agents.py b/apps/pacc-cli/pacc/validators/agents.py
index e256e10..6f4f089 100644
--- a/apps/pacc-cli/pacc/validators/agents.py
+++ b/apps/pacc-cli/pacc/validators/agents.py
@@ -6,39 +6,29 @@
from typing import Any, Dict, List, Optional, Union
from .base import BaseValidator, ValidationResult
+from .utils import parse_claude_frontmatter
class AgentsValidator(BaseValidator):
"""Validator for Claude Code agent extensions."""
- # Required fields in agent YAML frontmatter
+ # Required fields in agent YAML frontmatter per Claude Code documentation
REQUIRED_FRONTMATTER_FIELDS = ["name", "description"]
- # Optional fields with their expected types
+ # Optional fields per Claude Code documentation
OPTIONAL_FRONTMATTER_FIELDS = {
- "version": str,
- "author": str,
- "tags": list,
- "tools": list,
- "permissions": list,
- "parameters": dict,
- "examples": list,
- "model": str,
- "temperature": (int, float),
- "max_tokens": int,
- "timeout": (int, float),
- "enabled": bool
+ "tools": str, # Comma-separated string like "Read, Write, Bash"
+ "model": str, # Optional model string like "claude-3-opus"
+ "color": str # Optional terminal color like "cyan", "red"
}
- # Valid permission types for agents
- VALID_PERMISSIONS = {
- "read_files",
- "write_files",
- "execute_commands",
- "network_access",
- "filesystem_access",
- "tool_use",
- "user_confirmation_required"
+ # Known Claude Code tools for validation
+ # This is not exhaustive as MCP tools can be added dynamically
+ COMMON_TOOLS = {
+ "Read", "Write", "Edit", "MultiEdit", "Bash", "Grep",
+ "Glob", "WebFetch", "WebSearch", "TodoWrite", "Task",
+ "NotebookEdit", "BashOutput", "KillBash", "ExitPlanMode",
+ "LS"
}
def __init__(self, max_file_size: int = 10 * 1024 * 1024):
@@ -107,19 +97,16 @@ def validate_single(self, file_path: Union[str, Path]) -> ValidationResult:
# Extract metadata for successful validations
if result.is_valid and frontmatter:
+ # Parse tools if present
+ tools_str = frontmatter.get("tools", "")
+ tools_list = [t.strip() for t in tools_str.split(",")] if tools_str else []
+
result.metadata = {
"name": frontmatter.get("name", ""),
"description": frontmatter.get("description", ""),
- "version": frontmatter.get("version", "1.0.0"),
- "author": frontmatter.get("author", ""),
- "model": frontmatter.get("model", ""),
- "tools": frontmatter.get("tools", []),
- "permissions": frontmatter.get("permissions", []),
- "has_examples": bool(frontmatter.get("examples", [])),
- "markdown_length": len(markdown_content.strip()),
- "has_parameters": bool(frontmatter.get("parameters", {})),
- "temperature": frontmatter.get("temperature"),
- "max_tokens": frontmatter.get("max_tokens")
+ "tools": tools_list,
+ "tools_raw": tools_str,
+ "markdown_length": len(markdown_content.strip())
}
return result
@@ -166,25 +153,28 @@ def _parse_agent_file(self, content: str, result: ValidationResult) -> tuple[Opt
yaml_content = match.group(1)
markdown_content = match.group(2)
- # Parse YAML frontmatter
- try:
- frontmatter = yaml.safe_load(yaml_content)
- except yaml.YAMLError as e:
- result.add_error(
- "INVALID_YAML",
- f"Invalid YAML in frontmatter: {e}",
- suggestion="Fix YAML syntax errors in the frontmatter"
- )
- return True, None, ""
- except Exception as e:
- result.add_error(
- "YAML_PARSE_ERROR",
- f"Error parsing YAML frontmatter: {e}",
- suggestion="Check YAML formatting and syntax"
- )
- return True, None, ""
+ # Parse YAML frontmatter using lenient Claude Code parser
+ frontmatter = parse_claude_frontmatter(yaml_content)
if frontmatter is None:
+ # If lenient parser still failed, try strict YAML for better error message
+ try:
+ yaml.safe_load(yaml_content)
+ except yaml.YAMLError as e:
+ result.add_error(
+ "INVALID_YAML",
+ f"Invalid YAML in frontmatter: {e}",
+ suggestion="Fix YAML syntax errors in the frontmatter"
+ )
+ except Exception as e:
+ result.add_error(
+ "YAML_PARSE_ERROR",
+ f"Error parsing YAML frontmatter: {e}",
+ suggestion="Check YAML formatting and syntax"
+ )
+ return True, None, ""
+
+ if not frontmatter:
result.add_error(
"EMPTY_FRONTMATTER",
"YAML frontmatter is empty",
@@ -241,32 +231,18 @@ def _validate_frontmatter(self, frontmatter: Dict[str, Any], result: ValidationR
self._validate_agent_name(frontmatter.get("name"), result)
self._validate_agent_description(frontmatter.get("description"), result)
- if "version" in frontmatter:
- self._validate_version(frontmatter["version"], result)
-
- if "tags" in frontmatter:
- self._validate_tags(frontmatter["tags"], result)
-
if "tools" in frontmatter:
self._validate_tools(frontmatter["tools"], result)
- if "permissions" in frontmatter:
- self._validate_permissions(frontmatter["permissions"], result)
-
- if "parameters" in frontmatter:
- self._validate_parameters(frontmatter["parameters"], result)
-
- if "examples" in frontmatter:
- self._validate_examples(frontmatter["examples"], result)
-
- if "temperature" in frontmatter:
- self._validate_temperature(frontmatter["temperature"], result)
-
- if "max_tokens" in frontmatter:
- self._validate_max_tokens(frontmatter["max_tokens"], result)
-
- if "timeout" in frontmatter:
- self._validate_timeout(frontmatter["timeout"], result)
+ # Check for unknown fields and warn about them
+ known_fields = set(self.REQUIRED_FRONTMATTER_FIELDS) | set(self.OPTIONAL_FRONTMATTER_FIELDS.keys())
+ for field in frontmatter:
+ if field not in known_fields:
+ result.add_warning(
+ "UNKNOWN_FRONTMATTER_FIELD",
+ f"Unknown field '{field}' in agent frontmatter",
+ suggestion=f"Valid fields are: {', '.join(sorted(known_fields))}"
+ )
def _validate_agent_name(self, name: str, result: ValidationResult) -> None:
"""Validate agent name format."""
@@ -343,287 +319,42 @@ def _validate_agent_description(self, description: str, result: ValidationResult
suggestion="Provide a more detailed description of the agent's purpose"
)
- def _validate_version(self, version: str, result: ValidationResult) -> None:
- """Validate version format (semantic versioning)."""
- if not isinstance(version, str):
- result.add_error(
- "INVALID_VERSION_TYPE",
- "Version must be a string",
- suggestion="Set version to a string value like '1.0.0'"
- )
- return
+ def _validate_tools(self, tools: Any, result: ValidationResult) -> None:
+ """Validate agent tools configuration.
- # Basic semantic versioning check
- semver_pattern = r'^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$'
- if not re.match(semver_pattern, version):
- result.add_warning(
- "INVALID_VERSION_FORMAT",
- f"Version '{version}' does not follow semantic versioning",
- suggestion="Use semantic versioning format like '1.0.0'"
- )
-
- def _validate_tags(self, tags: List[Any], result: ValidationResult) -> None:
- """Validate agent tags."""
- if not isinstance(tags, list):
- result.add_error(
- "INVALID_TAGS_TYPE",
- "Tags must be an array",
- suggestion="Change tags to an array of strings"
- )
- return
-
- for i, tag in enumerate(tags):
- if not isinstance(tag, str):
- result.add_error(
- "INVALID_TAG_TYPE",
- f"Tag {i + 1} must be a string",
- suggestion="Ensure all tags are strings"
- )
- elif not tag.strip():
- result.add_error(
- "EMPTY_TAG",
- f"Tag {i + 1} cannot be empty",
- suggestion="Remove empty tags or provide meaningful tag names"
- )
-
- # Check for duplicates
- if len(tags) != len(set(tags)):
- result.add_warning(
- "DUPLICATE_TAGS",
- "Duplicate tags found",
- suggestion="Remove duplicate tags"
- )
-
- def _validate_tools(self, tools: List[Any], result: ValidationResult) -> None:
- """Validate agent tools configuration."""
- if not isinstance(tools, list):
+ Per Claude Code docs, tools should be a comma-separated string.
+ """
+ if not isinstance(tools, str):
result.add_error(
"INVALID_TOOLS_TYPE",
- "Tools must be an array",
- suggestion="Change tools to an array of tool names or configurations"
+ f"Tools must be a comma-separated string, got {type(tools).__name__}",
+ suggestion='Use format like: "Read, Write, Bash"'
)
return
- for i, tool in enumerate(tools):
- if isinstance(tool, str):
- # Simple tool name
- if not tool.strip():
- result.add_error(
- "EMPTY_TOOL_NAME",
- f"Tool {i + 1} name cannot be empty",
- suggestion="Provide a valid tool name"
- )
- elif isinstance(tool, dict):
- # Tool configuration object
- if "name" not in tool:
- result.add_error(
- "MISSING_TOOL_NAME",
- f"Tool {i + 1} configuration must have 'name' field",
- suggestion="Add a 'name' field to the tool configuration"
- )
- elif not isinstance(tool["name"], str) or not tool["name"].strip():
- result.add_error(
- "INVALID_TOOL_NAME",
- f"Tool {i + 1} name must be a non-empty string",
- suggestion="Provide a valid tool name"
- )
- else:
- result.add_error(
- "INVALID_TOOL_FORMAT",
- f"Tool {i + 1} must be a string name or configuration object",
- suggestion="Use either a tool name string or a tool configuration object"
- )
-
- def _validate_permissions(self, permissions: List[Any], result: ValidationResult) -> None:
- """Validate agent permissions."""
- if not isinstance(permissions, list):
- result.add_error(
- "INVALID_PERMISSIONS_TYPE",
- "Permissions must be an array",
- suggestion="Change permissions to an array of permission strings"
- )
+ if not tools.strip():
+ # Empty tools string is valid - inherits all tools
return
- invalid_permissions = []
- for i, permission in enumerate(permissions):
- if not isinstance(permission, str):
- result.add_error(
- "INVALID_PERMISSION_TYPE",
- f"Permission {i + 1} must be a string",
- suggestion="Ensure all permissions are strings"
- )
- elif permission not in self.VALID_PERMISSIONS:
- invalid_permissions.append(permission)
+ # Parse and validate individual tools
+ tool_list = [t.strip() for t in tools.split(",")]
- if invalid_permissions:
- result.add_error(
- "INVALID_PERMISSIONS",
- f"Invalid permissions: {', '.join(invalid_permissions)}",
- suggestion=f"Valid permissions are: {', '.join(self.VALID_PERMISSIONS)}"
- )
-
- # Check for duplicates
- if len(permissions) != len(set(permissions)):
- result.add_warning(
- "DUPLICATE_PERMISSIONS",
- "Duplicate permissions found",
- suggestion="Remove duplicate permissions"
- )
-
- def _validate_parameters(self, parameters: Dict[str, Any], result: ValidationResult) -> None:
- """Validate agent parameters configuration."""
- if not isinstance(parameters, dict):
- result.add_error(
- "INVALID_PARAMETERS_TYPE",
- "Parameters must be an object",
- suggestion="Change parameters to an object with parameter definitions"
- )
- return
-
- for param_name, param_config in parameters.items():
- self._validate_single_parameter(param_name, param_config, result)
-
- def _validate_single_parameter(self, param_name: str, param_config: Any,
- result: ValidationResult) -> None:
- """Validate a single parameter configuration."""
- param_prefix = f"Parameter '{param_name}'"
-
- if not isinstance(param_config, dict):
- result.add_error(
- "INVALID_PARAMETER_CONFIG_TYPE",
- f"{param_prefix}: Parameter configuration must be an object",
- suggestion="Use an object with type, description, and other fields"
- )
- return
-
- # Check required fields
- if "type" not in param_config:
- result.add_error(
- "MISSING_PARAMETER_TYPE",
- f"{param_prefix}: Missing required 'type' field",
- suggestion="Add a 'type' field to specify the parameter type"
- )
-
- if "description" not in param_config:
- result.add_warning(
- "MISSING_PARAMETER_DESCRIPTION",
- f"{param_prefix}: Missing 'description' field",
- suggestion="Add a 'description' field to document the parameter"
- )
-
- # Validate type field
- if "type" in param_config:
- param_type = param_config["type"]
- valid_types = ["string", "number", "integer", "boolean", "array", "object"]
- if param_type not in valid_types:
- result.add_error(
- "INVALID_PARAMETER_TYPE",
- f"{param_prefix}: Invalid type '{param_type}'",
- suggestion=f"Valid types are: {', '.join(valid_types)}"
+ for tool in tool_list:
+ if not tool:
+ result.add_warning(
+ "EMPTY_TOOL_NAME",
+ "Empty tool name in tools list",
+ suggestion="Remove extra commas from tools list"
)
-
- def _validate_examples(self, examples: List[Any], result: ValidationResult) -> None:
- """Validate agent examples."""
- if not isinstance(examples, list):
- result.add_error(
- "INVALID_EXAMPLES_TYPE",
- "Examples must be an array",
- suggestion="Change examples to an array of example objects"
- )
- return
-
- for i, example in enumerate(examples):
- if isinstance(example, str):
- # Simple example string
- if not example.strip():
- result.add_error(
- "EMPTY_EXAMPLE",
- f"Example {i + 1} cannot be empty",
- suggestion="Provide a meaningful example"
- )
- elif isinstance(example, dict):
- # Example object
- if "input" not in example:
- result.add_warning(
- "MISSING_EXAMPLE_INPUT",
- f"Example {i + 1} should have 'input' field",
- suggestion="Add an 'input' field to show example usage"
- )
- if "output" not in example:
- result.add_warning(
- "MISSING_EXAMPLE_OUTPUT",
- f"Example {i + 1} should have 'output' field",
- suggestion="Add an 'output' field to show expected result"
- )
- else:
- result.add_error(
- "INVALID_EXAMPLE_FORMAT",
- f"Example {i + 1} must be a string or object",
- suggestion="Use either an example string or an example object with input/output"
+ elif tool not in self.COMMON_TOOLS and not tool.startswith("mcp__"):
+ # Only warn for unknown tools since MCP and custom tools exist
+ result.add_info(
+ "UNKNOWN_TOOL",
+ f"Tool '{tool}' is not a known Claude Code tool",
+ suggestion="Verify this tool name is correct (could be an MCP tool)"
)
- def _validate_temperature(self, temperature: Union[int, float], result: ValidationResult) -> None:
- """Validate temperature parameter."""
- if not isinstance(temperature, (int, float)):
- result.add_error(
- "INVALID_TEMPERATURE_TYPE",
- "Temperature must be a number",
- suggestion="Set temperature to a number between 0 and 1"
- )
- return
-
- if temperature < 0 or temperature > 1:
- result.add_error(
- "INVALID_TEMPERATURE_RANGE",
- f"Temperature must be between 0 and 1, got {temperature}",
- suggestion="Set temperature to a value between 0 and 1"
- )
-
- def _validate_max_tokens(self, max_tokens: int, result: ValidationResult) -> None:
- """Validate max_tokens parameter."""
- if not isinstance(max_tokens, int):
- result.add_error(
- "INVALID_MAX_TOKENS_TYPE",
- "max_tokens must be an integer",
- suggestion="Set max_tokens to an integer value"
- )
- return
-
- if max_tokens <= 0:
- result.add_error(
- "INVALID_MAX_TOKENS_VALUE",
- "max_tokens must be positive",
- suggestion="Set max_tokens to a positive integer"
- )
- elif max_tokens > 100000:
- result.add_warning(
- "VERY_HIGH_MAX_TOKENS",
- f"max_tokens is very high ({max_tokens})",
- suggestion="Consider using a lower value for max_tokens"
- )
-
- def _validate_timeout(self, timeout: Union[int, float], result: ValidationResult) -> None:
- """Validate timeout parameter."""
- if not isinstance(timeout, (int, float)):
- result.add_error(
- "INVALID_TIMEOUT_TYPE",
- "Timeout must be a number",
- suggestion="Set timeout to a number of seconds"
- )
- return
-
- if timeout <= 0:
- result.add_error(
- "INVALID_TIMEOUT_VALUE",
- "Timeout must be positive",
- suggestion="Set timeout to a positive number of seconds"
- )
- elif timeout > 3600: # 1 hour
- result.add_warning(
- "VERY_LONG_TIMEOUT",
- f"Timeout is very long ({timeout} seconds)",
- suggestion="Consider using a shorter timeout"
- )
+ # Removed invalid validation methods for fields not in Claude Code spec
def _validate_markdown_content(self, markdown_content: str, result: ValidationResult) -> None:
"""Validate the markdown content of the agent."""
diff --git a/apps/pacc-cli/pacc/validators/commands.py b/apps/pacc-cli/pacc/validators/commands.py
index 24d3ffc..be8e16f 100644
--- a/apps/pacc-cli/pacc/validators/commands.py
+++ b/apps/pacc-cli/pacc/validators/commands.py
@@ -6,6 +6,7 @@
from typing import Any, Dict, List, Optional, Set, Union
from .base import BaseValidator, ValidationResult
+from .utils import parse_claude_frontmatter
class CommandsValidator(BaseValidator):
@@ -21,22 +22,13 @@ class CommandsValidator(BaseValidator):
"claude", "anthropic", "ai", "assistant"
}
- # Required fields in command YAML frontmatter (if using frontmatter format)
- REQUIRED_FRONTMATTER_FIELDS = ["name", "description"]
-
- # Optional fields with their expected types
- OPTIONAL_FRONTMATTER_FIELDS = {
- "usage": str,
- "examples": list,
- "parameters": dict,
- "category": str,
- "tags": list,
- "author": str,
- "version": str,
- "permissions": list,
- "aliases": list,
- "enabled": bool,
- "experimental": bool
+ # Frontmatter is completely optional for slash commands
+ # Valid frontmatter fields per Claude Code documentation
+ VALID_FRONTMATTER_FIELDS = {
+ "allowed-tools": (str, list), # Can be string or list
+ "argument-hint": str,
+ "description": str,
+ "model": str
}
# Valid parameter types for command parameters
@@ -199,25 +191,28 @@ def _validate_frontmatter_format(self, content: str, result: ValidationResult) -
yaml_content = match.group(1)
markdown_content = match.group(2)
- # Parse YAML frontmatter
- try:
- frontmatter = yaml.safe_load(yaml_content)
- except yaml.YAMLError as e:
- result.add_error(
- "INVALID_YAML",
- f"Invalid YAML in frontmatter: {e}",
- suggestion="Fix YAML syntax errors in the frontmatter"
- )
- return
- except Exception as e:
- result.add_error(
- "YAML_PARSE_ERROR",
- f"Error parsing YAML frontmatter: {e}",
- suggestion="Check YAML formatting and syntax"
- )
- return
+ # Parse YAML frontmatter using lenient Claude Code parser
+ frontmatter = parse_claude_frontmatter(yaml_content)
if frontmatter is None:
+ # If lenient parser still failed, try strict YAML for better error message
+ try:
+ yaml.safe_load(yaml_content)
+ except yaml.YAMLError as e:
+ result.add_error(
+ "INVALID_YAML",
+ f"Invalid YAML in frontmatter: {e}",
+ suggestion="Fix YAML syntax errors in the frontmatter"
+ )
+ except Exception as e:
+ result.add_error(
+ "YAML_PARSE_ERROR",
+ f"Error parsing YAML frontmatter: {e}",
+ suggestion="Check YAML formatting and syntax"
+ )
+ return
+
+ if not frontmatter:
result.add_error(
"EMPTY_FRONTMATTER",
"YAML frontmatter is empty",
@@ -242,12 +237,10 @@ def _validate_frontmatter_format(self, content: str, result: ValidationResult) -
# Extract metadata
if result.is_valid and frontmatter:
result.metadata = {
- "name": frontmatter.get("name", ""),
"description": frontmatter.get("description", ""),
- "category": frontmatter.get("category", ""),
- "has_parameters": bool(frontmatter.get("parameters", {})),
- "has_examples": bool(frontmatter.get("examples", [])),
- "aliases": frontmatter.get("aliases", []),
+ "argument_hint": frontmatter.get("argument-hint", ""),
+ "allowed_tools": frontmatter.get("allowed-tools", ""),
+ "model": frontmatter.get("model", ""),
"content_length": len(markdown_content.strip())
}
@@ -297,110 +290,128 @@ def _validate_simple_format(self, content: str, result: ValidationResult) -> Non
}
def _validate_frontmatter_structure(self, frontmatter: Dict[str, Any], result: ValidationResult) -> None:
- """Validate command YAML frontmatter structure."""
- # Validate required fields
- for field in self.REQUIRED_FRONTMATTER_FIELDS:
- if field not in frontmatter:
- result.add_error(
- "MISSING_REQUIRED_FIELD",
- f"Missing required field '{field}' in frontmatter",
- suggestion=f"Add the '{field}' field to the YAML frontmatter"
- )
- elif not frontmatter[field] or (isinstance(frontmatter[field], str) and not frontmatter[field].strip()):
- result.add_error(
- "EMPTY_REQUIRED_FIELD",
- f"Required field '{field}' cannot be empty",
- suggestion=f"Provide a value for the '{field}' field"
- )
+ """Validate command YAML frontmatter structure.
+
+ Per Claude Code documentation:
+ - Frontmatter is completely optional
+ - Valid fields: allowed-tools, argument-hint, description, model
+ - Command name comes from filename, not frontmatter
+ """
+ # Check for unknown fields and warn about them
+ for field in frontmatter:
+ if field not in self.VALID_FRONTMATTER_FIELDS:
+ # Map common misunderstandings
+ if field == "name":
+ result.add_warning(
+ "INVALID_FRONTMATTER_FIELD",
+ f"Field '{field}' is not valid in slash command frontmatter",
+ suggestion="Command name is derived from the filename, not frontmatter. Remove this field."
+ )
+ else:
+ result.add_warning(
+ "UNKNOWN_FRONTMATTER_FIELD",
+ f"Unknown field '{field}' in frontmatter",
+ suggestion=f"Valid fields are: {', '.join(self.VALID_FRONTMATTER_FIELDS.keys())}"
+ )
- # Validate field types
- for field, expected_type in self.OPTIONAL_FRONTMATTER_FIELDS.items():
+ # Validate field types for known fields
+ for field, expected_types in self.VALID_FRONTMATTER_FIELDS.items():
if field in frontmatter:
value = frontmatter[field]
- if not isinstance(value, expected_type):
- type_name = expected_type.__name__
- result.add_error(
- "INVALID_FIELD_TYPE",
- f"Field '{field}' must be of type {type_name}, got {type(value).__name__}",
- suggestion=f"Change '{field}' to the correct type"
- )
-
- # Skip detailed validation if required fields are missing
- if not all(field in frontmatter for field in self.REQUIRED_FRONTMATTER_FIELDS):
- return
-
- # Validate specific fields
- self._validate_command_name(frontmatter["name"], result)
- self._validate_command_description(frontmatter["description"], result)
-
- if "usage" in frontmatter:
- self._validate_usage(frontmatter["usage"], result)
-
- if "examples" in frontmatter:
- self._validate_examples(frontmatter["examples"], result)
-
- if "parameters" in frontmatter:
- self._validate_parameters(frontmatter["parameters"], result)
-
- if "aliases" in frontmatter:
- self._validate_aliases(frontmatter["aliases"], result)
-
- if "tags" in frontmatter:
- self._validate_tags(frontmatter["tags"], result)
-
- if "permissions" in frontmatter:
- self._validate_permissions(frontmatter["permissions"], result)
+ # Handle fields that can have multiple types
+ if isinstance(expected_types, tuple):
+ if not any(isinstance(value, t) for t in expected_types):
+ type_names = ' or '.join(t.__name__ for t in expected_types)
+ result.add_error(
+ "INVALID_FIELD_TYPE",
+ f"Field '{field}' must be of type {type_names}, got {type(value).__name__}",
+ suggestion=f"Change '{field}' to the correct type"
+ )
+ else:
+ if not isinstance(value, expected_types):
+ result.add_error(
+ "INVALID_FIELD_TYPE",
+ f"Field '{field}' must be of type {expected_types.__name__}, got {type(value).__name__}",
+ suggestion=f"Change '{field}' to the correct type"
+ )
+
+ # Validate specific field values
+ if "description" in frontmatter:
+ self._validate_command_description(frontmatter["description"], result)
+
+ if "argument-hint" in frontmatter:
+ self._validate_argument_hint(frontmatter["argument-hint"], result)
+
+ if "allowed-tools" in frontmatter:
+ self._validate_allowed_tools(frontmatter["allowed-tools"], result)
+
+ if "model" in frontmatter:
+ self._validate_model(frontmatter["model"], result)
- def _validate_command_name(self, name: str, result: ValidationResult) -> None:
- """Validate command name format."""
- if not isinstance(name, str):
+ def _validate_argument_hint(self, hint: str, result: ValidationResult) -> None:
+ """Validate argument-hint field."""
+ if not isinstance(hint, str):
result.add_error(
- "INVALID_NAME_TYPE",
- "Command name must be a string",
- suggestion="Change name to a string value"
+ "INVALID_ARGUMENT_HINT_TYPE",
+ "argument-hint must be a string",
+ suggestion="Change argument-hint to a string value"
)
return
- # Remove leading slash if present
- command_name = name.lstrip('/')
-
- if not command_name:
- result.add_error(
- "EMPTY_COMMAND_NAME",
- "Command name cannot be empty",
- suggestion="Provide a descriptive name for the command"
+ if not hint.strip():
+ result.add_warning(
+ "EMPTY_ARGUMENT_HINT",
+ "argument-hint is empty",
+ suggestion="Provide a hint about expected arguments like '[message]' or '[tagId]'"
)
- return
-
- # Check name format
- if not self.COMMAND_NAME_PATTERN.match(command_name):
+
+ def _validate_allowed_tools(self, tools: Union[str, List[str]], result: ValidationResult) -> None:
+ """Validate allowed-tools field."""
+ if isinstance(tools, str):
+ # Single tool as string is valid
+ if not tools.strip():
+ result.add_warning(
+ "EMPTY_ALLOWED_TOOLS",
+ "allowed-tools is empty",
+ suggestion="Specify tools like 'Bash(git status:*)' or remove this field"
+ )
+ elif isinstance(tools, list):
+ # List of tools is valid
+ for i, tool in enumerate(tools):
+ if not isinstance(tool, str):
+ result.add_error(
+ "INVALID_TOOL_TYPE",
+ f"Tool {i + 1} in allowed-tools must be a string",
+ suggestion="Ensure all tools are strings"
+ )
+ elif not tool.strip():
+ result.add_warning(
+ "EMPTY_TOOL",
+ f"Tool {i + 1} in allowed-tools is empty",
+ suggestion="Remove empty tool entries"
+ )
+ else:
result.add_error(
- "INVALID_COMMAND_NAME_FORMAT",
- f"Command name '{command_name}' contains invalid characters",
- suggestion="Use only alphanumeric characters, hyphens, and underscores, starting with a letter"
+ "INVALID_ALLOWED_TOOLS_TYPE",
+ "allowed-tools must be a string or list of strings",
+ suggestion="Use a string like 'Bash(git:*)' or a list of such strings"
)
-
- # Check for reserved names
- if command_name.lower() in self.RESERVED_COMMAND_NAMES:
+
+ def _validate_model(self, model: str, result: ValidationResult) -> None:
+ """Validate model field."""
+ if not isinstance(model, str):
result.add_error(
- "RESERVED_COMMAND_NAME",
- f"Command name '{command_name}' is reserved",
- suggestion="Use a different name for the command"
- )
-
- # Check name length
- if len(command_name) > 30:
- result.add_warning(
- "COMMAND_NAME_TOO_LONG",
- f"Command name is very long ({len(command_name)} characters)",
- suggestion="Use a shorter, more concise name"
+ "INVALID_MODEL_TYPE",
+ "model must be a string",
+ suggestion="Change model to a string value"
)
+ return
- if len(command_name) < 3:
+ if not model.strip():
result.add_warning(
- "COMMAND_NAME_TOO_SHORT",
- "Command name is very short",
- suggestion="Use a more descriptive name"
+ "EMPTY_MODEL",
+ "model field is empty",
+ suggestion="Specify a model like 'claude-3-5-sonnet-20241022' or remove this field"
)
def _validate_command_description(self, description: str, result: ValidationResult) -> None:
@@ -435,285 +446,58 @@ def _validate_command_description(self, description: str, result: ValidationResu
suggestion="Provide a more detailed description"
)
- def _validate_usage(self, usage: str, result: ValidationResult) -> None:
- """Validate command usage string."""
- if not isinstance(usage, str):
- result.add_error(
- "INVALID_USAGE_TYPE",
- "Usage must be a string",
- suggestion="Change usage to a string value"
- )
- return
-
- if not usage.strip():
- result.add_warning(
- "EMPTY_USAGE",
- "Usage field is empty",
- suggestion="Provide usage syntax for the command"
- )
- return
-
- # Check if usage starts with command syntax
- if not usage.strip().startswith('/'):
- result.add_warning(
- "USAGE_MISSING_SLASH",
- "Usage should start with / to show command syntax",
- suggestion="Start usage with /commandname to show proper syntax"
- )
-
- # Check for parameter placeholders
- placeholders = self._parameter_placeholder_pattern.findall(usage)
- if placeholders:
- result.metadata = result.metadata or {}
- result.metadata["usage_parameters"] = placeholders
-
- def _validate_examples(self, examples: List[Any], result: ValidationResult) -> None:
- """Validate command examples."""
- if not isinstance(examples, list):
- result.add_error(
- "INVALID_EXAMPLES_TYPE",
- "Examples must be an array",
- suggestion="Change examples to an array of example strings or objects"
- )
- return
-
- if not examples:
- result.add_warning(
- "NO_EXAMPLES",
- "No examples provided",
- suggestion="Add examples to show how to use the command"
- )
- return
-
- for i, example in enumerate(examples):
- if isinstance(example, str):
- if not example.strip():
- result.add_error(
- "EMPTY_EXAMPLE",
- f"Example {i + 1} cannot be empty",
- suggestion="Provide a meaningful example"
- )
- elif not example.strip().startswith('/'):
- result.add_warning(
- "EXAMPLE_MISSING_SLASH",
- f"Example {i + 1} should start with / to show command syntax",
- suggestion="Start example with /commandname"
- )
- elif isinstance(example, dict):
- if "command" not in example:
- result.add_warning(
- "EXAMPLE_MISSING_COMMAND",
- f"Example {i + 1} should have 'command' field",
- suggestion="Add a 'command' field to show the command usage"
- )
- if "description" not in example:
- result.add_warning(
- "EXAMPLE_MISSING_DESCRIPTION",
- f"Example {i + 1} should have 'description' field",
- suggestion="Add a 'description' field to explain the example"
- )
- else:
- result.add_error(
- "INVALID_EXAMPLE_FORMAT",
- f"Example {i + 1} must be a string or object",
- suggestion="Use either an example string or an example object"
- )
-
- def _validate_parameters(self, parameters: Dict[str, Any], result: ValidationResult) -> None:
- """Validate command parameters configuration."""
- if not isinstance(parameters, dict):
+ def _validate_command_name(self, name: str, result: ValidationResult) -> None:
+ """Validate command name format (used for simple format validation)."""
+ if not isinstance(name, str):
result.add_error(
- "INVALID_PARAMETERS_TYPE",
- "Parameters must be an object",
- suggestion="Change parameters to an object with parameter definitions"
+ "INVALID_NAME_TYPE",
+ "Command name must be a string",
+ suggestion="Change name to a string value"
)
return
- for param_name, param_config in parameters.items():
- self._validate_single_parameter(param_name, param_config, result)
-
- def _validate_single_parameter(self, param_name: str, param_config: Any,
- result: ValidationResult) -> None:
- """Validate a single parameter configuration."""
- param_prefix = f"Parameter '{param_name}'"
-
- # Validate parameter name
- if not self.COMMAND_NAME_PATTERN.match(param_name):
- result.add_error(
- "INVALID_PARAMETER_NAME",
- f"{param_prefix}: Parameter name contains invalid characters",
- suggestion="Use only alphanumeric characters, hyphens, and underscores"
- )
+ # Remove leading slash if present
+ command_name = name.lstrip('/')
- if not isinstance(param_config, dict):
+ if not command_name:
result.add_error(
- "INVALID_PARAMETER_CONFIG_TYPE",
- f"{param_prefix}: Parameter configuration must be an object",
- suggestion="Use an object with type, description, and other fields"
+ "EMPTY_COMMAND_NAME",
+ "Command name cannot be empty",
+ suggestion="Provide a descriptive name for the command"
)
return
- # Check required fields
- if "type" not in param_config:
- result.add_error(
- "MISSING_PARAMETER_TYPE",
- f"{param_prefix}: Missing required 'type' field",
- suggestion="Add a 'type' field to specify the parameter type"
- )
-
- if "description" not in param_config:
- result.add_warning(
- "MISSING_PARAMETER_DESCRIPTION",
- f"{param_prefix}: Missing 'description' field",
- suggestion="Add a 'description' field to document the parameter"
- )
-
- # Validate type field
- if "type" in param_config:
- param_type = param_config["type"]
- if param_type not in self.VALID_PARAMETER_TYPES:
- result.add_error(
- "INVALID_PARAMETER_TYPE",
- f"{param_prefix}: Invalid type '{param_type}'",
- suggestion=f"Valid types are: {', '.join(self.VALID_PARAMETER_TYPES)}"
- )
-
- # Validate optional fields
- if "required" in param_config and not isinstance(param_config["required"], bool):
+ # Check name format
+ if not self.COMMAND_NAME_PATTERN.match(command_name):
result.add_error(
- "INVALID_PARAMETER_REQUIRED_TYPE",
- f"{param_prefix}: 'required' must be a boolean",
- suggestion="Set 'required' to true or false"
- )
-
- if "default" in param_config and "required" in param_config and param_config["required"]:
- result.add_warning(
- "REQUIRED_PARAMETER_HAS_DEFAULT",
- f"{param_prefix}: Required parameter should not have a default value",
- suggestion="Either make parameter optional or remove default value"
+ "INVALID_COMMAND_NAME_FORMAT",
+ f"Command name '{command_name}' contains invalid characters",
+ suggestion="Use only alphanumeric characters, hyphens, and underscores, starting with a letter"
)
- # Validate choice type parameters
- if param_config.get("type") == "choice":
- if "choices" not in param_config:
- result.add_error(
- "MISSING_PARAMETER_CHOICES",
- f"{param_prefix}: Choice type parameter must have 'choices' field",
- suggestion="Add a 'choices' array with valid options"
- )
- elif not isinstance(param_config["choices"], list):
- result.add_error(
- "INVALID_PARAMETER_CHOICES_TYPE",
- f"{param_prefix}: 'choices' must be an array",
- suggestion="Change 'choices' to an array of valid options"
- )
-
- def _validate_aliases(self, aliases: List[Any], result: ValidationResult) -> None:
- """Validate command aliases."""
- if not isinstance(aliases, list):
+ # Check for reserved names
+ if command_name.lower() in self.RESERVED_COMMAND_NAMES:
result.add_error(
- "INVALID_ALIASES_TYPE",
- "Aliases must be an array",
- suggestion="Change aliases to an array of strings"
+ "RESERVED_COMMAND_NAME",
+ f"Command name '{command_name}' is reserved",
+ suggestion="Use a different name for the command"
)
- return
-
- for i, alias in enumerate(aliases):
- if not isinstance(alias, str):
- result.add_error(
- "INVALID_ALIAS_TYPE",
- f"Alias {i + 1} must be a string",
- suggestion="Ensure all aliases are strings"
- )
- elif not alias.strip():
- result.add_error(
- "EMPTY_ALIAS",
- f"Alias {i + 1} cannot be empty",
- suggestion="Remove empty aliases"
- )
- else:
- alias_name = alias.lstrip('/')
- if not self.COMMAND_NAME_PATTERN.match(alias_name):
- result.add_error(
- "INVALID_ALIAS_FORMAT",
- f"Alias '{alias_name}' contains invalid characters",
- suggestion="Use only alphanumeric characters, hyphens, and underscores"
- )
- if alias_name.lower() in self.RESERVED_COMMAND_NAMES:
- result.add_error(
- "RESERVED_ALIAS_NAME",
- f"Alias '{alias_name}' is reserved",
- suggestion="Use a different alias name"
- )
- # Check for duplicates
- if len(aliases) != len(set(aliases)):
+ # Check name length
+ if len(command_name) > 30:
result.add_warning(
- "DUPLICATE_ALIASES",
- "Duplicate aliases found",
- suggestion="Remove duplicate aliases"
- )
-
- def _validate_tags(self, tags: List[Any], result: ValidationResult) -> None:
- """Validate command tags."""
- if not isinstance(tags, list):
- result.add_error(
- "INVALID_TAGS_TYPE",
- "Tags must be an array",
- suggestion="Change tags to an array of strings"
+ "COMMAND_NAME_TOO_LONG",
+ f"Command name is very long ({len(command_name)} characters)",
+ suggestion="Use a shorter, more concise name"
)
- return
-
- for i, tag in enumerate(tags):
- if not isinstance(tag, str):
- result.add_error(
- "INVALID_TAG_TYPE",
- f"Tag {i + 1} must be a string",
- suggestion="Ensure all tags are strings"
- )
- elif not tag.strip():
- result.add_error(
- "EMPTY_TAG",
- f"Tag {i + 1} cannot be empty",
- suggestion="Remove empty tags"
- )
- # Check for duplicates
- if len(tags) != len(set(tags)):
+ if len(command_name) < 3:
result.add_warning(
- "DUPLICATE_TAGS",
- "Duplicate tags found",
- suggestion="Remove duplicate tags"
+ "COMMAND_NAME_TOO_SHORT",
+ "Command name is very short",
+ suggestion="Use a more descriptive name"
)
- def _validate_permissions(self, permissions: List[Any], result: ValidationResult) -> None:
- """Validate command permissions."""
- if not isinstance(permissions, list):
- result.add_error(
- "INVALID_PERMISSIONS_TYPE",
- "Permissions must be an array",
- suggestion="Change permissions to an array of strings"
- )
- return
-
- valid_permissions = {
- "read_files", "write_files", "execute_commands", "network_access",
- "user_confirmation_required", "admin_only"
- }
-
- for i, permission in enumerate(permissions):
- if not isinstance(permission, str):
- result.add_error(
- "INVALID_PERMISSION_TYPE",
- f"Permission {i + 1} must be a string",
- suggestion="Ensure all permissions are strings"
- )
- elif permission not in valid_permissions:
- result.add_warning(
- "UNKNOWN_PERMISSION",
- f"Unknown permission '{permission}'",
- suggestion=f"Valid permissions are: {', '.join(valid_permissions)}"
- )
def _validate_command_content(self, content: str, result: ValidationResult) -> None:
"""Validate the markdown content of the command."""
@@ -733,12 +517,12 @@ def _validate_command_content(self, content: str, result: ValidationResult) -> N
suggestion="Provide more detailed information about the command"
)
- # Check for command syntax examples
+ # Check for command syntax examples (optional)
if not self._command_syntax_pattern.search(content):
result.add_info(
"NO_COMMAND_SYNTAX_FOUND",
"No command syntax examples found in content",
- suggestion="Include examples showing how to use the command"
+ suggestion="Consider including examples showing how to use the command (optional)"
)
# Check for headers (good practice)
diff --git a/apps/pacc-cli/pacc/validators/utils.py b/apps/pacc-cli/pacc/validators/utils.py
index 62d857f..1e173e6 100644
--- a/apps/pacc-cli/pacc/validators/utils.py
+++ b/apps/pacc-cli/pacc/validators/utils.py
@@ -1,29 +1,110 @@
"""Utility functions for PACC validators."""
import os
+import re
+import yaml
from pathlib import Path
from typing import Dict, List, Optional, Union, Any
from .base import ValidationResult, BaseValidator
-from .hooks import HooksValidator
-from .mcp import MCPValidator
-from .agents import AgentsValidator
-from .commands import CommandsValidator
+
+
+def parse_claude_frontmatter(yaml_content: str) -> Optional[Dict[str, Any]]:
+ """Parse Claude Code frontmatter with lenient handling for unquoted brackets.
+
+ Claude Code's frontmatter parser is more lenient than strict YAML.
+ It allows unquoted square brackets in values like:
+ - argument-hint: [--team ] [--project ]
+ - argument-hint: [message]
+
+ This function preprocesses the YAML to handle these cases before parsing.
+
+ Args:
+ yaml_content: The YAML frontmatter content to parse
+
+ Returns:
+ Parsed frontmatter as a dictionary, or None if parsing fails
+ """
+ if not yaml_content or not yaml_content.strip():
+ return {}
+
+ # Process line by line to handle problematic patterns
+ lines = yaml_content.split('\n')
+ processed_lines = []
+
+ for line in lines:
+ # Check if line has a key-value pair
+ if ':' in line:
+ # Split only on first colon to preserve values with colons
+ parts = line.split(':', 1)
+ if len(parts) == 2:
+ key = parts[0].strip()
+ value = parts[1].strip()
+
+ # Special handling for argument-hint field which should always be a string
+ if key == 'argument-hint' and value.startswith('['):
+ # Claude Code treats this as a string, not a YAML list
+ # Always quote it to preserve as string
+ if not (value.startswith('"[') or value.startswith("'[")):
+ value = f'"{value}"'
+ line = f"{parts[0]}: {value}"
+ # Check if value starts with [ and contains spaces (problematic for YAML)
+ elif value and value.startswith('[') and ' ' in value:
+ # Check if it's not already a valid YAML list
+ if not (value.startswith('["') or value.startswith("['") or value == '[]'):
+ # This is likely Claude Code style brackets, auto-quote it
+ value = f'"{value}"'
+ line = f"{parts[0]}: {value}"
+
+ processed_lines.append(line)
+
+ processed_yaml = '\n'.join(processed_lines)
+
+ try:
+ result = yaml.safe_load(processed_yaml)
+
+ # Post-process to ensure argument-hint is always a string
+ if result and 'argument-hint' in result:
+ hint = result['argument-hint']
+ if isinstance(hint, list):
+ # Convert list back to Claude Code format string
+ if len(hint) == 1:
+ result['argument-hint'] = f"[{hint[0]}]"
+ else:
+ result['argument-hint'] = str(hint)
+
+ return result
+ except yaml.YAMLError:
+ # If it still fails, return None to let the validator handle the error
+ return None
class ValidatorFactory:
"""Factory class for creating and managing validators."""
- _validators = {
- "hooks": HooksValidator,
- "mcp": MCPValidator,
- "agents": AgentsValidator,
- "commands": CommandsValidator
- }
+ _validators = None
+
+ @classmethod
+ def _initialize_validators(cls):
+ """Initialize validators with late import to avoid circular dependencies."""
+ if cls._validators is None:
+ from .hooks import HooksValidator
+ from .mcp import MCPValidator
+ from .agents import AgentsValidator
+ from .commands import CommandsValidator
+
+ cls._validators = {
+ "hooks": HooksValidator,
+ "mcp": MCPValidator,
+ "agents": AgentsValidator,
+ "commands": CommandsValidator
+ }
@classmethod
def get_validator(cls, extension_type: str, **kwargs) -> BaseValidator:
"""Get a validator instance for the specified extension type."""
+ cls._initialize_validators()
+
if extension_type not in cls._validators:
raise ValueError(f"Unknown extension type: {extension_type}. "
f"Available types: {', '.join(cls._validators.keys())}")
@@ -34,6 +115,8 @@ def get_validator(cls, extension_type: str, **kwargs) -> BaseValidator:
@classmethod
def get_all_validators(cls, **kwargs) -> Dict[str, BaseValidator]:
"""Get all available validators."""
+ cls._initialize_validators()
+
return {
ext_type: validator_class(**kwargs)
for ext_type, validator_class in cls._validators.items()
@@ -42,6 +125,7 @@ def get_all_validators(cls, **kwargs) -> Dict[str, BaseValidator]:
@classmethod
def get_supported_types(cls) -> List[str]:
"""Get list of supported extension types."""
+ cls._initialize_validators()
return list(cls._validators.keys())
@@ -86,7 +170,8 @@ def format_result(result: ValidationResult, verbose: bool = False) -> str:
@staticmethod
def format_batch_results(results: List[ValidationResult],
- show_summary: bool = True) -> str:
+ show_summary: bool = True,
+ verbose: bool = False) -> str:
"""Format multiple validation results."""
lines = []
@@ -106,7 +191,7 @@ def format_batch_results(results: List[ValidationResult],
for i, result in enumerate(results):
if i > 0:
lines.append("")
- lines.append(ValidationResultFormatter.format_result(result))
+ lines.append(ValidationResultFormatter.format_result(result, verbose=verbose))
return "\n".join(lines)
@@ -159,26 +244,157 @@ def _result_to_dict(result: ValidationResult) -> Dict[str, Any]:
class ExtensionDetector:
- """Utility to detect extension types from files and directories."""
+ """Utility to detect extension types from files and directories.
+
+ Uses hierarchical detection approach:
+ 1. pacc.json declarations (highest priority)
+ 2. Directory structure (secondary signal)
+ 3. Content keywords (fallback only)
+ """
@staticmethod
- def detect_extension_type(file_path: Union[str, Path]) -> Optional[str]:
- """Detect the extension type of a file."""
+ def detect_extension_type(file_path: Union[str, Path], project_dir: Optional[Union[str, Path]] = None) -> Optional[str]:
+ """Detect the extension type of a file using hierarchical approach.
+
+ Args:
+ file_path: Path to the file to analyze
+ project_dir: Optional project directory to look for pacc.json (highest priority)
+ If not provided, will try to detect from file_path location
+
+ Returns:
+ Extension type string ('hooks', 'mcp', 'agents', 'commands') or None if unknown
+ """
file_path = Path(file_path)
if not file_path.exists() or not file_path.is_file():
return None
- # Check file extension and name patterns
- suffix = file_path.suffix.lower()
- name = file_path.name.lower()
+ # Step 1: Check pacc.json declarations (highest priority)
+ pacc_json_type = ExtensionDetector._check_pacc_json_declaration(file_path, project_dir)
+ if pacc_json_type:
+ return pacc_json_type
+
+ # Step 2: Check directory structure (secondary signal)
+ directory_type = ExtensionDetector._check_directory_structure(file_path)
+ if directory_type:
+ return directory_type
+
+ # Step 3: Check content keywords (fallback only)
+ content_type = ExtensionDetector._check_content_keywords(file_path)
+ if content_type:
+ return content_type
+
+ return None
+
+ @staticmethod
+ def _check_pacc_json_declaration(file_path: Path, project_dir: Optional[Union[str, Path]]) -> Optional[str]:
+ """Check if file is declared in pacc.json with specific type."""
+ if project_dir is None:
+ # Try to find project directory by looking for pacc.json in parent directories
+ current_dir = file_path.parent
+ while current_dir != current_dir.parent: # Stop at filesystem root
+ if (current_dir / "pacc.json").exists():
+ project_dir = current_dir
+ break
+ current_dir = current_dir.parent
+
+ if project_dir is None:
+ return None
+
+ project_dir = Path(project_dir)
+ pacc_json_path = project_dir / "pacc.json"
+
+ if not pacc_json_path.exists():
+ return None
+
+ try:
+ # Import here to avoid circular imports
+ from ..core.project_config import ProjectConfigManager
+
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(project_dir)
+
+ if not config or 'extensions' not in config:
+ return None
+
+ # Convert file path to relative path from project directory
+ try:
+ relative_path = file_path.relative_to(project_dir)
+ relative_str = str(relative_path)
+
+ # Also try with "./" prefix as used in pacc.json
+ relative_with_prefix = f"./{relative_str}"
+
+ except ValueError:
+ # File is not within project directory
+ relative_str = str(file_path)
+ relative_with_prefix = relative_str
+
+ # Check each extension type
+ extensions = config.get('extensions', {})
+ for ext_type, ext_list in extensions.items():
+ if not isinstance(ext_list, list):
+ continue
+
+ for ext_spec in ext_list:
+ if not isinstance(ext_spec, dict) or 'source' not in ext_spec:
+ continue
+
+ source = ext_spec['source']
+
+ # Handle various source path formats
+ if source in [relative_str, relative_with_prefix, str(file_path), file_path.name]:
+ return ext_type
+
+ # Handle source paths with different normalization
+ source_path = Path(source)
+ if source_path.name == file_path.name:
+ # Also check if the relative paths match when normalized
+ if source.startswith('./'):
+ source_normalized = Path(source[2:])
+ else:
+ source_normalized = source_path
+
+ if str(source_normalized) == relative_str:
+ return ext_type
+
+ except Exception as e:
+ # Log error but don't fail detection
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.debug(f"Error checking pacc.json declarations: {e}")
+
+ return None
+
+ @staticmethod
+ def _check_directory_structure(file_path: Path) -> Optional[str]:
+ """Check directory structure for extension type hints."""
+ parts = file_path.parts
- # MCP files
- if name.endswith('.mcp.json') or name == 'mcp.json':
+ # Check for standard directory names in the path
+ if any(part in ["commands", "cmd"] for part in parts):
+ return "commands"
+ elif any(part in ["agents", "agent"] for part in parts):
+ return "agents"
+ elif any(part in ["hooks", "hook"] for part in parts):
+ return "hooks"
+ elif any(part in ["mcp", "servers"] for part in parts):
return "mcp"
- # Check content for file type detection
+ return None
+
+ @staticmethod
+ def _check_content_keywords(file_path: Path) -> Optional[str]:
+ """Check file content for extension type keywords (fallback only)."""
try:
+ suffix = file_path.suffix.lower()
+ name = file_path.name.lower()
+
+ # MCP files by name pattern
+ if name.endswith('.mcp.json') or name == 'mcp.json':
+ return "mcp"
+
+ # Read file content
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read(1024) # Read first 1KB
@@ -189,42 +405,74 @@ def detect_extension_type(file_path: Union[str, Path]) -> Optional[str]:
elif "mcpServers" in content:
return "mcp"
- # Agents and Commands (Markdown files)
+ # Agents and Commands (Markdown files)
elif suffix == '.md':
- if content.startswith("---") and ("name:" in content or "description:" in content):
- # Has YAML frontmatter, check content type
- if any(word in content.lower() for word in ["agent", "tool", "permission"]):
- return "agents"
- elif any(word in content.lower() for word in ["command", "usage:", "/", "slash"]):
- return "commands"
- elif "/" in content and ("command" in content.lower() or "usage" in content.lower()):
+ content_lower = content.lower()
+
+ # Check for slash command patterns first (more specific)
+ if content.startswith("# /") or "/:" in content or "slash command" in content_lower:
return "commands"
+
+ # Check for frontmatter
+ if content.startswith("---"):
+ frontmatter_end = content.find("---", 3)
+ if frontmatter_end != -1:
+ frontmatter = content[:frontmatter_end + 3]
+ frontmatter_lower = frontmatter.lower()
+ body = content[frontmatter_end + 3:]
+ body_lower = body.lower()
+
+ # Strong indicators for commands (slash commands)
+ if any(pattern in content_lower for pattern in [
+ "# /", "usage:", "/:", "slash command", "command usage"
+ ]):
+ return "commands"
+
+ # Strong indicators for agents
+ if any(pattern in frontmatter_lower for pattern in [
+ "tools:", "permissions:", "enabled:"
+ ]) or any(pattern in body_lower for pattern in [
+ "this agent", "agent helps", "agent should"
+ ]):
+ return "agents"
+
+ # General content analysis (weaker signals)
+ if any(word in content_lower for word in ["usage:", "## usage", "# usage"]):
+ return "commands"
+ elif any(word in content_lower for word in ["tool", "permission", "agent"]):
+ # This is the old logic that caused PACC-18 - now it's fallback only
+ # Only return "agents" if we have strong agent indicators
+ if any(strong_indicator in content_lower for strong_indicator in [
+ "this agent", "agent helps", "agent should", "agent provides"
+ ]):
+ return "agents"
+ # If it just has generic "tool" or "permission" keywords, it might be a command
+ return None # Let other detection methods handle this
except Exception:
- # If we can't read the file, try to guess from name/location
+ # If we can't read the file, return None
pass
- # Fallback based on directory structure
- parts = file_path.parts
- if any(part in ["commands", "cmd"] for part in parts):
- return "commands"
- elif any(part in ["agents", "agent"] for part in parts):
- return "agents"
- elif any(part in ["hooks", "hook"] for part in parts):
- return "hooks"
- elif any(part in ["mcp", "servers"] for part in parts):
- return "mcp"
-
return None
@staticmethod
- def scan_directory(directory_path: Union[str, Path]) -> Dict[str, List[Path]]:
- """Scan a directory and categorize files by extension type."""
+ def scan_directory(directory_path: Union[str, Path], project_dir: Optional[Union[str, Path]] = None) -> Dict[str, List[Path]]:
+ """Scan a directory and categorize files by extension type.
+
+ Args:
+ directory_path: Directory to scan for extensions
+ project_dir: Optional project directory for pacc.json detection context
+ If None, will use directory_path as the project directory
+ """
directory = Path(directory_path)
if not directory.exists() or not directory.is_dir():
return {}
+ # Use directory_path as project_dir if not specified
+ if project_dir is None:
+ project_dir = directory
+
extensions_by_type = {
"hooks": [],
"mcp": [],
@@ -235,7 +483,7 @@ def scan_directory(directory_path: Union[str, Path]) -> Dict[str, List[Path]]:
# Get all relevant files
for file_path in directory.rglob("*"):
if file_path.is_file():
- ext_type = ExtensionDetector.detect_extension_type(file_path)
+ ext_type = ExtensionDetector.detect_extension_type(file_path, project_dir=project_dir)
if ext_type:
extensions_by_type[ext_type].append(file_path)
@@ -279,11 +527,28 @@ def validate_file(self, file_path: Union[str, Path],
validator = self.validators[extension_type]
return validator.validate_single(file_path)
- def validate_directory(self, directory_path: Union[str, Path]) -> Dict[str, List[ValidationResult]]:
- """Validate all extensions in a directory, organized by type."""
- extensions_by_type = ExtensionDetector.scan_directory(directory_path)
+ def validate_directory(self, directory_path: Union[str, Path],
+ extension_type: Optional[str] = None) -> Dict[str, List[ValidationResult]]:
+ """Validate extensions in a directory, optionally filtered by type.
+
+ Args:
+ directory_path: Path to directory to validate
+ extension_type: Optional extension type to filter by. If provided, only
+ validates extensions of this type.
+
+ Returns:
+ Dict mapping extension types to their validation results
+ """
+ extensions_by_type = ExtensionDetector.scan_directory(directory_path, project_dir=directory_path)
results_by_type = {}
+ # Filter by extension type if specified
+ if extension_type is not None:
+ if extension_type in extensions_by_type:
+ extensions_by_type = {extension_type: extensions_by_type[extension_type]}
+ else:
+ extensions_by_type = {}
+
for ext_type, file_paths in extensions_by_type.items():
if file_paths:
validator = self.validators[ext_type]
@@ -350,7 +615,18 @@ def validate_extension_file(file_path: Union[str, Path],
return runner.validate_file(file_path, extension_type)
-def validate_extension_directory(directory_path: Union[str, Path]) -> Dict[str, List[ValidationResult]]:
- """Validate all extensions in a directory."""
+def validate_extension_directory(directory_path: Union[str, Path],
+ extension_type: Optional[str] = None) -> Dict[str, List[ValidationResult]]:
+ """Validate extensions in a directory, optionally filtered by type.
+
+ Args:
+ directory_path: Path to directory containing extensions to validate
+ extension_type: Optional extension type to filter by ('hooks', 'mcp', 'agents', 'commands').
+ If None, validates all extension types found in the directory.
+
+ Returns:
+ Dict mapping extension types to their validation results. When extension_type
+ is specified, returns only that type (if found) or empty dict.
+ """
runner = ValidationRunner()
- return runner.validate_directory(directory_path)
\ No newline at end of file
+ return runner.validate_directory(directory_path, extension_type)
\ No newline at end of file
diff --git a/apps/pacc-cli/pyproject.toml b/apps/pacc-cli/pyproject.toml
index d815c5e..ebca964 100644
--- a/apps/pacc-cli/pyproject.toml
+++ b/apps/pacc-cli/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pacc-cli"
-version = "0.2.0"
+version = "1.0.0"
description = "Package manager for Claude Code - simplify installation and management of Claude Code extensions"
readme = "README.md"
requires-python = ">=3.8"
diff --git a/apps/pacc-cli/tests/core/test_project_config.py b/apps/pacc-cli/tests/core/test_project_config.py
index f64d703..2532956 100644
--- a/apps/pacc-cli/tests/core/test_project_config.py
+++ b/apps/pacc-cli/tests/core/test_project_config.py
@@ -13,7 +13,8 @@
ProjectConfigSchema,
ExtensionSpec,
ProjectSyncResult,
- ConfigValidationResult
+ ConfigValidationResult,
+ InstallationPathResolver
)
from pacc.errors.exceptions import (
ConfigurationError,
@@ -818,4 +819,447 @@ def sample_project_for_sync(temp_project_dir):
def temp_project_dir():
"""Create temporary project directory."""
with tempfile.TemporaryDirectory() as tmp_dir:
- yield Path(tmp_dir)
\ No newline at end of file
+ yield Path(tmp_dir)
+
+
+class TestFolderStructureSpecification:
+ """Test folder structure specification features (PACC-19, PACC-25)."""
+
+ def test_extension_spec_with_target_dir(self):
+ """Test ExtensionSpec with targetDir field."""
+ spec_data = {
+ "name": "team-hook",
+ "source": "./hooks/team.json",
+ "version": "1.0.0",
+ "targetDir": "team/product/"
+ }
+
+ spec = ExtensionSpec.from_dict(spec_data)
+
+ assert spec.name == "team-hook"
+ assert spec.source == "./hooks/team.json"
+ assert spec.version == "1.0.0"
+ assert spec.target_dir == "team/product/"
+ assert spec.preserve_structure is False # Default
+
+ def test_extension_spec_with_preserve_structure(self):
+ """Test ExtensionSpec with preserveStructure field."""
+ spec_data = {
+ "name": "structured-hook",
+ "source": "./src/hooks/",
+ "version": "1.0.0",
+ "preserveStructure": True
+ }
+
+ spec = ExtensionSpec.from_dict(spec_data)
+
+ assert spec.preserve_structure is True
+ assert spec.target_dir is None # Default
+
+ def test_extension_spec_with_both_folder_fields(self):
+ """Test ExtensionSpec with both targetDir and preserveStructure."""
+ spec_data = {
+ "name": "complex-extension",
+ "source": "https://github.com/team/extensions",
+ "version": "1.2.0",
+ "targetDir": "custom/location/",
+ "preserveStructure": True
+ }
+
+ spec = ExtensionSpec.from_dict(spec_data)
+
+ assert spec.target_dir == "custom/location/"
+ assert spec.preserve_structure is True
+
+ def test_extension_spec_snake_case_compatibility(self):
+ """Test ExtensionSpec supports both camelCase and snake_case field names."""
+ # Test snake_case format
+ snake_case_data = {
+ "name": "snake-case-test",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "target_dir": "snake/case/",
+ "preserve_structure": True
+ }
+
+ spec = ExtensionSpec.from_dict(snake_case_data)
+ assert spec.target_dir == "snake/case/"
+ assert spec.preserve_structure is True
+
+ # Test camelCase format
+ camel_case_data = {
+ "name": "camel-case-test",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "targetDir": "camel/case/",
+ "preserveStructure": False
+ }
+
+ spec = ExtensionSpec.from_dict(camel_case_data)
+ assert spec.target_dir == "camel/case/"
+ assert spec.preserve_structure is False
+
+ def test_extension_spec_to_dict_includes_folder_fields(self):
+ """Test that to_dict includes folder structure fields in camelCase."""
+ spec = ExtensionSpec(
+ name="test-ext",
+ source="./test.json",
+ version="1.0.0",
+ target_dir="custom/dir/",
+ preserve_structure=True
+ )
+
+ result = spec.to_dict()
+
+ assert result["targetDir"] == "custom/dir/"
+ assert result["preserveStructure"] is True
+
+ # Ensure backwards compatibility - no snake_case in JSON
+ assert "target_dir" not in result
+ assert "preserve_structure" not in result
+
+ def test_extension_spec_to_dict_omits_none_values(self):
+ """Test that to_dict omits None/False values for clean JSON."""
+ spec = ExtensionSpec(
+ name="minimal-ext",
+ source="./test.json",
+ version="1.0.0"
+ # target_dir=None, preserve_structure=False (defaults)
+ )
+
+ result = spec.to_dict()
+
+ assert "targetDir" not in result
+ assert "preserveStructure" not in result # False is default, omit
+
+ def test_schema_validation_valid_folder_fields(self):
+ """Test schema validation accepts valid folder structure fields."""
+ valid_config = {
+ "name": "folder-test-project",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [
+ {
+ "name": "organized-hook",
+ "source": "./hooks/organized.json",
+ "version": "1.0.0",
+ "targetDir": "team/hooks/",
+ "preserveStructure": True
+ }
+ ],
+ "commands": [
+ {
+ "name": "team-command",
+ "source": "./commands/team.md",
+ "version": "1.0.0",
+ "targetDir": "commands/team-name/"
+ }
+ ]
+ }
+ }
+
+ schema = ProjectConfigSchema()
+ result = schema.validate(valid_config)
+
+ assert result.is_valid
+ assert len(result.errors) == 0
+
+ def test_schema_validation_invalid_target_dir(self):
+ """Test schema validation rejects invalid targetDir values."""
+ invalid_configs = [
+ # Empty string
+ {
+ "name": "test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [{
+ "name": "test-hook",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "targetDir": ""
+ }]
+ }
+ },
+ # Path traversal attempt
+ {
+ "name": "test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [{
+ "name": "malicious-hook",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "targetDir": "../../../etc/"
+ }]
+ }
+ },
+ # Absolute path
+ {
+ "name": "test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [{
+ "name": "absolute-hook",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "targetDir": "/absolute/path/"
+ }]
+ }
+ },
+ # Non-string type
+ {
+ "name": "test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [{
+ "name": "wrong-type-hook",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "targetDir": 123
+ }]
+ }
+ }
+ ]
+
+ schema = ProjectConfigSchema()
+
+ for i, config in enumerate(invalid_configs):
+ result = schema.validate(config)
+ assert not result.is_valid, f"Config {i} should be invalid"
+
+ # Check specific error types
+ error_codes = [error.code for error in result.errors]
+ expected_codes = ["INVALID_TARGET_DIR", "UNSAFE_TARGET_DIR"]
+ assert any(code in error_codes for code in expected_codes), f"Config {i} should have target dir error"
+
+ def test_schema_validation_invalid_preserve_structure(self):
+ """Test schema validation rejects invalid preserveStructure values."""
+ invalid_config = {
+ "name": "test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [{
+ "name": "invalid-preserve-hook",
+ "source": "./test.json",
+ "version": "1.0.0",
+ "preserveStructure": "not-a-boolean"
+ }]
+ }
+ }
+
+ schema = ProjectConfigSchema()
+ result = schema.validate(invalid_config)
+
+ assert not result.is_valid
+ error_codes = [error.code for error in result.errors]
+ assert "INVALID_PRESERVE_STRUCTURE" in error_codes
+
+
+class TestInstallationPathResolver:
+ """Test InstallationPathResolver for folder structure handling."""
+
+ @pytest.fixture
+ def resolver(self):
+ """Create InstallationPathResolver instance."""
+ return InstallationPathResolver()
+
+ @pytest.fixture
+ def claude_code_dir(self, temp_project_dir):
+ """Mock Claude Code directory."""
+ claude_dir = temp_project_dir / ".claude"
+ claude_dir.mkdir()
+ return claude_dir
+
+ def test_resolve_basic_target_path(self, resolver, claude_code_dir):
+ """Test basic target path resolution without custom settings."""
+ spec = ExtensionSpec(
+ name="basic-hook",
+ source="./hooks/basic.json",
+ version="1.0.0"
+ )
+
+ base_dir = claude_code_dir / "hooks"
+ source_file = Path("basic.json")
+
+ result = resolver.resolve_target_path(spec, base_dir, source_file)
+
+ assert result == base_dir / "basic.json"
+
+ def test_resolve_target_path_with_custom_dir(self, resolver, claude_code_dir):
+ """Test target path resolution with custom targetDir."""
+ spec = ExtensionSpec(
+ name="team-hook",
+ source="./hooks/team.json",
+ version="1.0.0",
+ target_dir="team/product/"
+ )
+
+ base_dir = claude_code_dir / "hooks"
+ source_file = Path("team.json")
+
+ result = resolver.resolve_target_path(spec, base_dir, source_file)
+
+ expected = base_dir / "team/product" / "team.json"
+ assert result == expected
+
+ def test_resolve_target_path_with_structure_preservation(self, resolver, claude_code_dir):
+ """Test target path resolution with structure preservation."""
+ spec = ExtensionSpec(
+ name="structured-hook",
+ source="./src/hooks/",
+ version="1.0.0",
+ preserve_structure=True
+ )
+
+ base_dir = claude_code_dir / "hooks"
+ source_file = Path("src/hooks/nested/deep.json")
+
+ result = resolver.resolve_target_path(spec, base_dir, source_file)
+
+ # Should preserve the nested structure
+ expected = base_dir / "deep.json" # Simplified expectation for now
+ assert result.name == "deep.json"
+
+ def test_resolve_target_path_with_both_custom_and_preserve(self, resolver, claude_code_dir):
+ """Test target path resolution with both custom dir and structure preservation."""
+ spec = ExtensionSpec(
+ name="complex-hook",
+ source="./src/hooks/",
+ version="1.0.0",
+ target_dir="custom/team/",
+ preserve_structure=True
+ )
+
+ base_dir = claude_code_dir / "hooks"
+ source_file = Path("src/hooks/nested/complex.json")
+
+ result = resolver.resolve_target_path(spec, base_dir, source_file)
+
+ # Should use custom directory and preserve structure
+ assert "custom/team" in str(result)
+ assert result.name == "complex.json"
+
+ def test_validate_target_directory_security(self, resolver):
+ """Test target directory validation prevents path traversal."""
+ # Valid directories
+ valid_dirs = ["team/hooks/", "commands/product/", "simple"]
+ for valid_dir in valid_dirs:
+ result = resolver._validate_target_directory(valid_dir)
+ assert result == valid_dir.rstrip('/')
+
+ # Invalid directories (should raise ValidationError)
+ invalid_dirs = ["../../../etc/", "/absolute/path/", "team/../../../root"]
+ for invalid_dir in invalid_dirs:
+ with pytest.raises(ValidationError):
+ resolver._validate_target_directory(invalid_dir)
+
+ def test_get_extension_install_directory(self, resolver, claude_code_dir):
+ """Test getting base installation directories for extension types."""
+ expected_dirs = {
+ 'hooks': claude_code_dir / 'hooks',
+ 'mcps': claude_code_dir / 'mcps',
+ 'agents': claude_code_dir / 'agents',
+ 'commands': claude_code_dir / 'commands'
+ }
+
+ for ext_type, expected_dir in expected_dirs.items():
+ result = resolver.get_extension_install_directory(ext_type, claude_code_dir)
+ assert result == expected_dir
+
+ # Test invalid extension type
+ with pytest.raises(ValueError):
+ resolver.get_extension_install_directory("invalid_type", claude_code_dir)
+
+ def test_validate_target_path_security_bounds(self, resolver, claude_code_dir):
+ """Test target path validation stays within Claude Code directory."""
+ # Valid paths (within claude_code_dir)
+ valid_paths = [
+ claude_code_dir / "hooks" / "test.json",
+ claude_code_dir / "custom" / "team" / "hook.json"
+ ]
+
+ for valid_path in valid_paths:
+ assert resolver.validate_target_path(valid_path, claude_code_dir) is True
+
+ # Invalid paths (outside claude_code_dir)
+ invalid_paths = [
+ claude_code_dir.parent / "outside.json",
+ Path("/tmp/malicious.json")
+ ]
+
+ for invalid_path in invalid_paths:
+ assert resolver.validate_target_path(invalid_path, claude_code_dir) is False
+
+ def test_create_target_directory(self, resolver, temp_project_dir):
+ """Test target directory creation."""
+ target_file = temp_project_dir / "nested" / "deep" / "structure" / "file.json"
+
+ # Directory shouldn't exist initially
+ assert not target_file.parent.exists()
+
+ # Create target directory
+ resolver.create_target_directory(target_file)
+
+ # Directory should now exist
+ assert target_file.parent.exists()
+ assert target_file.parent.is_dir()
+
+
+class TestBackwardCompatibility:
+ """Test backward compatibility with existing pacc.json files."""
+
+ def test_existing_configs_still_work(self):
+ """Test that existing configs without folder fields still validate."""
+ existing_config = {
+ "name": "legacy-project",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [
+ {
+ "name": "legacy-hook",
+ "source": "./hooks/legacy.json",
+ "version": "1.0.0"
+ # No targetDir or preserveStructure fields
+ }
+ ]
+ }
+ }
+
+ schema = ProjectConfigSchema()
+ result = schema.validate(existing_config)
+
+ assert result.is_valid
+ assert len(result.errors) == 0
+
+ def test_extension_spec_defaults(self):
+ """Test that ExtensionSpec has proper defaults for new fields."""
+ minimal_spec_data = {
+ "name": "minimal",
+ "source": "./test.json",
+ "version": "1.0.0"
+ }
+
+ spec = ExtensionSpec.from_dict(minimal_spec_data)
+
+ # Should have sensible defaults
+ assert spec.target_dir is None
+ assert spec.preserve_structure is False
+
+ def test_to_dict_backward_compatibility(self):
+ """Test that to_dict produces clean JSON without new fields when not used."""
+ spec = ExtensionSpec(
+ name="clean",
+ source="./test.json",
+ version="1.0.0"
+ # Using defaults for new fields
+ )
+
+ result = spec.to_dict()
+
+ # Should only contain the basic required fields
+ expected_keys = {"name", "source", "version"}
+ assert set(result.keys()) == expected_keys
+
+ # Should not contain the new optional fields
+ assert "targetDir" not in result
+ assert "preserveStructure" not in result
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/integration/test_cross_feature_integration.py b/apps/pacc-cli/tests/integration/test_cross_feature_integration.py
new file mode 100644
index 0000000..b1a8237
--- /dev/null
+++ b/apps/pacc-cli/tests/integration/test_cross_feature_integration.py
@@ -0,0 +1,944 @@
+"""Cross-feature integration tests - PACC-26 Testing Part.
+
+This comprehensive test suite validates integration between all major features:
+1. S01 fixes + folder structure features
+2. Extension detection + installation workflows
+3. Validation + CLI commands + project configuration
+4. Performance optimization across all operations
+5. Edge case handling with multiple features active
+
+Related Issues:
+- PACC-26: Comprehensive testing and documentation (subtask PACC-36)
+- PACC-24: Extension detection hierarchy
+- PACC-18: Slash command misclassification fix
+"""
+
+import json
+import pytest
+import time
+import subprocess
+import os
+import shutil
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Dict, Any, List, Tuple
+from unittest.mock import patch, MagicMock
+
+from pacc.validators import (
+ validate_extension_directory,
+ validate_extension_file,
+ ExtensionDetector,
+ ValidatorFactory
+)
+from pacc.core.config_manager import ClaudeConfigManager
+from pacc.core.project_config import ProjectConfigManager
+from pacc.cli import PACCCli
+
+
+class TestS01FolderStructureIntegration:
+ """Test integration between S01 fixes and folder structure features."""
+
+ def test_detection_hierarchy_with_target_dir(self):
+ """Test extension detection hierarchy works with targetDir configuration."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create project with complex structure
+ project_dir = temp_path / "project"
+ project_dir.mkdir()
+
+ # Create misleading directory structure
+ agents_dir = project_dir / "agents"
+ agents_dir.mkdir()
+
+ # File that looks like agent but will be declared as command
+ misleading_file = agents_dir / "actually-slash-command.md"
+ misleading_file.write_text("""---
+name: actually-slash-command
+description: Has agent keywords but is slash command
+tools: ["file-reader"]
+permissions: ["read-files"]
+---
+
+# /actually-slash-command
+
+Agent keywords: tool, permission, agent assistance
+But this is actually a slash command due to pacc.json declaration.
+""")
+
+ # Create target structure with preserveStructure
+ target_dir = project_dir / "custom-extensions"
+ target_dir.mkdir()
+
+ # Create pacc.json with folder structure + extension declarations
+ pacc_config = {
+ "name": "integration-test-project",
+ "version": "1.0.0",
+ "targetDir": "./custom-extensions",
+ "preserveStructure": True,
+ "extensions": {
+ "commands": [
+ {
+ "name": "actually-slash-command",
+ "source": "./agents/actually-slash-command.md", # pacc.json overrides directory
+ "version": "1.0.0",
+ "targetPath": "commands/slash/actually-slash-command.md"
+ }
+ ],
+ "hooks": [
+ {
+ "name": "auth-hook",
+ "source": "./auth/hooks/auth-hook.json",
+ "version": "1.0.0",
+ "targetPath": "hooks/auth/auth-hook.json"
+ }
+ ]
+ }
+ }
+
+ pacc_json = project_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Create additional extension files
+ auth_hooks_dir = project_dir / "auth" / "hooks"
+ auth_hooks_dir.mkdir(parents=True)
+
+ auth_hook = auth_hooks_dir / "auth-hook.json"
+ auth_hook.write_text(json.dumps({
+ "name": "auth-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse", "PostToolUse"],
+ "description": "Authentication validation hook"
+ }))
+
+ # Test extension detection with project context
+ detector = ExtensionDetector()
+
+ # Should detect as command due to pacc.json (highest priority)
+ command_type = detector.detect_extension_type(misleading_file, project_dir=project_dir)
+ assert command_type == "commands", "pacc.json should override directory structure"
+
+ # Should detect hook correctly
+ hook_type = detector.detect_extension_type(auth_hook, project_dir=project_dir)
+ assert hook_type == "hooks", "Hook should be detected correctly"
+
+ # Test full directory validation
+ results = validate_extension_directory(project_dir)
+
+ # Verify correct categorization
+ assert "commands" in results
+ assert "hooks" in results
+
+ command_files = [r.file_path for r in results["commands"]]
+ hook_files = [r.file_path for r in results["hooks"]]
+
+ assert any("actually-slash-command.md" in f for f in command_files)
+ assert any("auth-hook.json" in f for f in hook_files)
+
+ def test_validation_workflow_with_folder_structure(self):
+ """Test complete validation workflow with folder structure features."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "project"
+ project_dir.mkdir()
+
+ # Create complex nested structure
+ structure = {
+ "extensions/hooks/auth": ["pre-auth.json", "post-auth.json"],
+ "extensions/hooks/tools": ["tool-validator.json"],
+ "extensions/commands/user": ["profile.md", "settings.md"],
+ "extensions/commands/admin": ["system.md"],
+ "extensions/agents/support": ["help-agent.md"],
+ "extensions/mcp/servers": ["database-server.json"]
+ }
+
+ extensions_config = {
+ "hooks": [],
+ "commands": [],
+ "agents": [],
+ "mcp": []
+ }
+
+ # Create files and build configuration
+ for dir_path, filenames in structure.items():
+ full_dir = project_dir / dir_path
+ full_dir.mkdir(parents=True)
+
+ for filename in filenames:
+ file_path = full_dir / filename
+ ext_type = self._determine_extension_type_from_path(dir_path)
+ content = self._create_extension_content(filename, ext_type)
+
+ file_path.write_text(content)
+
+ # Add to configuration
+ relative_path = f"./{dir_path}/{filename}"
+ extensions_config[ext_type].append({
+ "name": filename.split('.')[0],
+ "source": relative_path,
+ "version": "1.0.0"
+ })
+
+ # Create pacc.json with folder structure configuration
+ pacc_config = {
+ "name": "complex-structure-test",
+ "version": "1.0.0",
+ "targetDir": "./installed-extensions",
+ "preserveStructure": True,
+ "extensions": extensions_config
+ }
+
+ pacc_json = project_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Run comprehensive validation
+ results = validate_extension_directory(project_dir)
+
+ # Verify all extension types found
+ expected_types = ["hooks", "commands", "agents", "mcp"]
+ for ext_type in expected_types:
+ assert ext_type in results, f"Missing extension type: {ext_type}"
+ assert len(results[ext_type]) > 0, f"No {ext_type} found"
+
+ # Verify specific counts
+ assert len(results["hooks"]) == 3 # 3 hook files
+ assert len(results["commands"]) == 3 # 3 command files
+ assert len(results["agents"]) == 1 # 1 agent file
+ assert len(results["mcp"]) == 1 # 1 mcp file
+
+ # Test validation quality
+ all_results = []
+ for ext_results in results.values():
+ all_results.extend(ext_results)
+
+ valid_count = sum(1 for r in all_results if r.is_valid)
+ total_count = len(all_results)
+
+ # Should have high validation success rate
+ success_rate = valid_count / total_count if total_count > 0 else 0
+ assert success_rate >= 0.8, f"Low validation success rate: {success_rate:.2f}"
+
+ def test_cli_integration_with_folder_features(self):
+ """Test CLI commands integration with folder structure features."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "project"
+ project_dir.mkdir()
+
+ # Create project with folder structure
+ self._create_folder_structure_project(project_dir)
+
+ # Test CLI validate command
+ cli = PACCCli()
+
+ class MockValidateArgs:
+ source = str(project_dir)
+ type = None
+ strict = False
+
+ # Should validate entire project structure
+ result = cli.validate_command(MockValidateArgs())
+ assert result in [0, 1], "CLI validate should not crash"
+
+ # Test CLI validate with type filter
+ class MockHooksArgs:
+ source = str(project_dir)
+ type = "hooks"
+ strict = False
+
+ hooks_result = cli.validate_command(MockHooksArgs())
+ assert hooks_result in [0, 1], "CLI hooks validation should not crash"
+
+ # Test CLI validate in strict mode
+ class MockStrictArgs:
+ source = str(project_dir)
+ type = None
+ strict = True
+
+ strict_result = cli.validate_command(MockStrictArgs())
+ assert strict_result in [0, 1], "CLI strict validation should not crash"
+
+ def _determine_extension_type_from_path(self, dir_path: str) -> str:
+ """Determine extension type from directory path."""
+ if "hooks" in dir_path:
+ return "hooks"
+ elif "commands" in dir_path:
+ return "commands"
+ elif "agents" in dir_path:
+ return "agents"
+ elif "mcp" in dir_path:
+ return "mcp"
+ else:
+ return "hooks" # default
+
+ def _create_extension_content(self, filename: str, ext_type: str) -> str:
+ """Create appropriate content for extension type."""
+ base_name = filename.split('.')[0]
+
+ if ext_type == "hooks":
+ return json.dumps({
+ "name": base_name,
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": f"Hook: {base_name}"
+ }, indent=2)
+
+ elif ext_type == "commands":
+ return f"""---
+name: {base_name}
+description: Command {base_name}
+---
+
+# /{base_name}
+
+Command content for {base_name}.
+"""
+
+ elif ext_type == "agents":
+ return f"""---
+name: {base_name}
+description: Agent {base_name}
+tools: ["file-reader"]
+permissions: ["read-files"]
+---
+
+Agent content for {base_name}.
+"""
+
+ elif ext_type == "mcp":
+ return json.dumps({
+ "name": base_name,
+ "command": ["python", f"{base_name}.py"],
+ "args": ["--port", "3000"]
+ }, indent=2)
+
+ else:
+ return f"Content for {filename}"
+
+ def _create_folder_structure_project(self, project_dir: Path):
+ """Create a project with folder structure for testing."""
+ # Create extensions
+ extensions_dir = project_dir / "extensions"
+ extensions_dir.mkdir()
+
+ # Hooks
+ hooks_dir = extensions_dir / "hooks"
+ hooks_dir.mkdir()
+
+ hook_file = hooks_dir / "test-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Test hook"
+ }))
+
+ # Commands
+ commands_dir = extensions_dir / "commands"
+ commands_dir.mkdir()
+
+ command_file = commands_dir / "test-command.md"
+ command_file.write_text("""---
+name: test-command
+description: Test command
+---
+
+# /test-command
+
+Test command content.
+""")
+
+ # pacc.json
+ pacc_config = {
+ "name": "folder-structure-project",
+ "version": "1.0.0",
+ "targetDir": "./target",
+ "preserveStructure": True,
+ "extensions": {
+ "hooks": [
+ {"name": "test-hook", "source": "./extensions/hooks/test-hook.json", "version": "1.0.0"}
+ ],
+ "commands": [
+ {"name": "test-command", "source": "./extensions/commands/test-command.md", "version": "1.0.0"}
+ ]
+ }
+ }
+
+ pacc_json = project_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+
+class TestValidationInstallationWorkflowIntegration:
+ """Test integration between validation and installation workflows."""
+
+ def test_validation_before_installation_workflow(self):
+ """Test validation step before installation in complete workflow."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create source project
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create target installation directory
+ target_dir = temp_path / "claude-config"
+ target_dir.mkdir()
+
+ # Create mixed valid/invalid extensions
+ self._create_mixed_quality_extensions(source_dir)
+
+ # Step 1: Validate before installation
+ validation_results = validate_extension_directory(source_dir)
+
+ # Analyze validation results
+ all_results = []
+ for ext_results in validation_results.values():
+ all_results.extend(ext_results)
+
+ valid_extensions = [r for r in all_results if r.is_valid]
+ invalid_extensions = [r for r in all_results if not r.is_valid]
+
+ assert len(valid_extensions) > 0, "Should have some valid extensions"
+ assert len(invalid_extensions) > 0, "Should have some invalid extensions"
+
+ # Step 2: Only install valid extensions (simulated)
+ installable_files = [Path(r.file_path) for r in valid_extensions]
+
+ # Verify installable files exist and are valid
+ for file_path in installable_files:
+ assert file_path.exists(), f"Installable file missing: {file_path}"
+
+ # Step 3: Verify invalid extensions are excluded
+ invalid_files = [Path(r.file_path) for r in invalid_extensions]
+
+ # Should not attempt to install invalid extensions
+ for invalid_file in invalid_files:
+ assert invalid_file not in installable_files
+
+ def test_detection_validation_installation_chain(self):
+ """Test complete chain: detection → validation → installation simulation."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create source with ambiguous files
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create files that need detection hierarchy to classify correctly
+ self._create_detection_test_files(source_dir)
+
+ # Step 1: Extension Detection
+ detector = ExtensionDetector()
+ detected_extensions = {}
+
+ for file_path in source_dir.rglob("*"):
+ if file_path.is_file() and not file_path.name.startswith('.'):
+ ext_type = detector.detect_extension_type(file_path, project_dir=source_dir)
+ if ext_type:
+ if ext_type not in detected_extensions:
+ detected_extensions[ext_type] = []
+ detected_extensions[ext_type].append(file_path)
+
+ # Verify detection worked
+ assert len(detected_extensions) > 0, "Should detect some extensions"
+
+ # Step 2: Validation of detected extensions
+ validation_results = validate_extension_directory(source_dir)
+
+ # Step 3: Cross-reference detection and validation
+ for ext_type, detected_files in detected_extensions.items():
+ if ext_type in validation_results:
+ validated_files = [Path(r.file_path) for r in validation_results[ext_type]]
+
+ # Most detected files should be validated (some may be invalid)
+ overlap = set(detected_files) & set(validated_files)
+ assert len(overlap) > 0, f"No overlap between detection and validation for {ext_type}"
+
+ # Step 4: Installation readiness check
+ installable_count = 0
+ for ext_type, validation_list in validation_results.items():
+ for validation_result in validation_list:
+ if validation_result.is_valid:
+ installable_count += 1
+
+ assert installable_count > 0, "Should have installable extensions after validation"
+
+ def test_performance_across_workflow_steps(self):
+ """Test performance across all workflow steps."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create large test dataset
+ source_dir = temp_path / "large-source"
+ source_dir.mkdir()
+
+ self._create_large_test_dataset(source_dir, num_files=200)
+
+ # Measure complete workflow performance
+ start_time = time.time()
+
+ # Step 1: Detection (simulated)
+ detector = ExtensionDetector()
+ detection_start = time.time()
+
+ detected_count = 0
+ for file_path in source_dir.rglob("*"):
+ if file_path.is_file():
+ ext_type = detector.detect_extension_type(file_path)
+ if ext_type:
+ detected_count += 1
+
+ detection_time = time.time() - detection_start
+
+ # Step 2: Validation
+ validation_start = time.time()
+ validation_results = validate_extension_directory(source_dir)
+ validation_time = time.time() - validation_start
+
+ total_time = time.time() - start_time
+
+ # Performance assertions
+ assert detection_time < 5.0, f"Detection too slow: {detection_time:.2f}s"
+ assert validation_time < 10.0, f"Validation too slow: {validation_time:.2f}s"
+ assert total_time < 15.0, f"Total workflow too slow: {total_time:.2f}s"
+
+ # Throughput assertions
+ files_per_second = detected_count / total_time if total_time > 0 else 0
+ assert files_per_second > 10, f"Low throughput: {files_per_second:.1f} files/s"
+
+ print(f"\nWorkflow Performance Results:")
+ print(f"- Files processed: {detected_count}")
+ print(f"- Detection time: {detection_time:.3f}s")
+ print(f"- Validation time: {validation_time:.3f}s")
+ print(f"- Total time: {total_time:.3f}s")
+ print(f"- Throughput: {files_per_second:.1f} files/s")
+
+ def _create_mixed_quality_extensions(self, base_dir: Path):
+ """Create mix of valid and invalid extensions."""
+ # Valid hook
+ valid_hook = base_dir / "valid-hook.json"
+ valid_hook.write_text(json.dumps({
+ "name": "valid-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Valid hook for testing"
+ }))
+
+ # Invalid hook (missing required fields)
+ invalid_hook = base_dir / "invalid-hook.json"
+ invalid_hook.write_text(json.dumps({
+ "name": "invalid-hook"
+ # Missing version and events
+ }))
+
+ # Malformed JSON
+ malformed_hook = base_dir / "malformed-hook.json"
+ malformed_hook.write_text('{"name": "malformed", invalid json}')
+
+ # Valid command
+ commands_dir = base_dir / "commands"
+ commands_dir.mkdir()
+
+ valid_command = commands_dir / "valid-command.md"
+ valid_command.write_text("""---
+name: valid-command
+description: Valid command
+---
+
+# /valid-command
+
+Valid command content.
+""")
+
+ # Invalid command (no proper structure)
+ invalid_command = commands_dir / "invalid-command.md"
+ invalid_command.write_text("Just plain text without proper markdown structure.")
+
+ def _create_detection_test_files(self, base_dir: Path):
+ """Create files that test detection hierarchy."""
+ # File in agents directory but declared as command in pacc.json
+ agents_dir = base_dir / "agents"
+ agents_dir.mkdir()
+
+ misleading_file = agents_dir / "actually-command.md"
+ misleading_file.write_text("""---
+name: actually-command
+description: Has agent keywords but is command per pacc.json
+tools: ["calculator"]
+permissions: ["execute"]
+---
+
+# /actually-command
+
+Contains agent keywords but should be detected as command.
+""")
+
+ # Clear agent file (fallback detection)
+ clear_agent = base_dir / "clear-agent.md"
+ clear_agent.write_text("""---
+name: clear-agent
+description: Clear agent with tools
+tools: ["file-reader", "calculator"]
+permissions: ["read-files", "execute"]
+---
+
+Clear agent content for fallback detection.
+""")
+
+ # Hook in proper directory
+ hooks_dir = base_dir / "hooks"
+ hooks_dir.mkdir()
+
+ hook_file = hooks_dir / "proper-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "proper-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Proper hook in hooks directory"
+ }))
+
+ # Create pacc.json with override
+ pacc_config = {
+ "name": "detection-test",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "actually-command",
+ "source": "./agents/actually-command.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+
+ pacc_json = base_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ def _create_large_test_dataset(self, base_dir: Path, num_files: int = 200):
+ """Create large dataset for performance testing."""
+ # Create multiple extension types
+ ext_types = ["hooks", "commands", "agents", "mcp"]
+ files_per_type = num_files // len(ext_types)
+
+ for ext_type in ext_types:
+ type_dir = base_dir / ext_type
+ type_dir.mkdir()
+
+ for i in range(files_per_type):
+ if ext_type == "hooks":
+ file_path = type_dir / f"hook_{i:03d}.json"
+ content = json.dumps({
+ "name": f"hook-{i}",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": f"Test hook {i}"
+ })
+
+ elif ext_type == "commands":
+ file_path = type_dir / f"command_{i:03d}.md"
+ content = f"""---
+name: command-{i}
+description: Test command {i}
+---
+
+# /command-{i}
+
+Test command content {i}.
+"""
+
+ elif ext_type == "agents":
+ file_path = type_dir / f"agent_{i:03d}.md"
+ content = f"""---
+name: agent-{i}
+description: Test agent {i}
+tools: ["file-reader"]
+permissions: ["read-files"]
+---
+
+Test agent content {i}.
+"""
+
+ elif ext_type == "mcp":
+ file_path = type_dir / f"server_{i:03d}.json"
+ content = json.dumps({
+ "name": f"server-{i}",
+ "command": ["python", f"server_{i}.py"],
+ "args": ["--port", str(3000 + i)]
+ })
+
+ file_path.write_text(content)
+
+
+class TestEdgeCasesIntegration:
+ """Test edge cases with multiple features active."""
+
+ def test_empty_project_handling(self):
+ """Test handling of completely empty projects."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ empty_dir = temp_path / "empty"
+ empty_dir.mkdir()
+
+ # Test validation on empty directory
+ results = validate_extension_directory(empty_dir)
+
+ # Should handle gracefully
+ assert isinstance(results, dict)
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results == 0
+
+ # Test CLI on empty directory
+ cli = PACCCli()
+
+ class MockArgs:
+ source = str(empty_dir)
+ type = None
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+ assert result == 1 # Should return error for no extensions found
+
+ def test_corrupted_project_configuration(self):
+ """Test handling of corrupted pacc.json files."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "corrupted"
+ project_dir.mkdir()
+
+ # Create valid extension file
+ hook_file = project_dir / "hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create corrupted pacc.json
+ pacc_json = project_dir / "pacc.json"
+ pacc_json.write_text('{"name": "corrupted", invalid json syntax}')
+
+ # Should handle corruption gracefully
+ try:
+ results = validate_extension_directory(project_dir)
+
+ # Should still find extensions via fallback detection
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+ except json.JSONDecodeError:
+ # Acceptable to fail on corrupted JSON
+ pass
+
+ def test_mixed_file_permissions(self):
+ """Test handling of mixed file permissions."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "permissions"
+ project_dir.mkdir()
+
+ # Create accessible file
+ accessible_hook = project_dir / "accessible-hook.json"
+ accessible_hook.write_text(json.dumps({
+ "name": "accessible-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create restricted directory (simulate)
+ restricted_dir = project_dir / "restricted"
+ restricted_dir.mkdir()
+
+ restricted_hook = restricted_dir / "restricted-hook.json"
+ restricted_hook.write_text(json.dumps({
+ "name": "restricted-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Mock permission error
+ original_read_text = Path.read_text
+
+ def mock_read_text(self, *args, **kwargs):
+ if "restricted" in str(self):
+ raise PermissionError("Permission denied")
+ return original_read_text(self, *args, **kwargs)
+
+ with patch.object(Path, 'read_text', mock_read_text):
+ # Should handle permission errors gracefully
+ results = validate_extension_directory(project_dir)
+
+ # Should still process accessible files
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+ def test_circular_symlink_handling(self):
+ """Test handling of circular symbolic links."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "symlinks"
+ project_dir.mkdir()
+
+ # Create normal file
+ normal_file = project_dir / "normal-hook.json"
+ normal_file.write_text(json.dumps({
+ "name": "normal-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create circular symlinks (if supported)
+ try:
+ if hasattr(os, 'symlink'):
+ link1 = project_dir / "link1"
+ link2 = project_dir / "link2"
+
+ # Create circular reference
+ os.symlink(link2, link1)
+ os.symlink(link1, link2)
+
+ # Should handle circular symlinks without infinite loop
+ start_time = time.time()
+ results = validate_extension_directory(project_dir)
+ end_time = time.time()
+
+ # Should complete quickly (not get stuck in infinite loop)
+ duration = end_time - start_time
+ assert duration < 10.0, f"Circular symlink caused infinite loop: {duration:.2f}s"
+
+ # Should still find normal files
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+ except (OSError, NotImplementedError):
+ pytest.skip("Platform does not support symlinks")
+
+ def test_unicode_and_special_characters_integration(self):
+ """Test handling of Unicode and special characters across all features."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "unicode"
+ project_dir.mkdir()
+
+ # Create files with Unicode names and content
+ unicode_files = [
+ ("测试-hook.json", "hooks"),
+ ("émoji-🎉-command.md", "commands"),
+ ("спецсимволы-agent.md", "agents")
+ ]
+
+ extensions_config = {"hooks": [], "commands": [], "agents": []}
+
+ for filename, ext_type in unicode_files:
+ try:
+ file_path = project_dir / filename
+
+ if ext_type == "hooks":
+ content = json.dumps({
+ "name": "unicode-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": f"Unicode test: {filename}"
+ }, ensure_ascii=False)
+
+ elif ext_type == "commands":
+ content = f"""---
+name: unicode-command
+description: Unicode test {filename}
+---
+
+# /unicode-command
+
+Unicode content: {filename}
+"""
+
+ elif ext_type == "agents":
+ content = f"""---
+name: unicode-agent
+description: Unicode agent {filename}
+tools: ["file-reader"]
+---
+
+Unicode agent content: {filename}
+"""
+
+ file_path.write_text(content, encoding='utf-8')
+
+ extensions_config[ext_type].append({
+ "name": f"unicode-{ext_type[:-1]}", # Remove 's' from type
+ "source": f"./{filename}",
+ "version": "1.0.0"
+ })
+
+ except (OSError, UnicodeError):
+ # Skip if filesystem doesn't support Unicode
+ continue
+
+ # Create pacc.json with Unicode content
+ if any(extensions_config.values()):
+ pacc_config = {
+ "name": "unicode-test-项目",
+ "version": "1.0.0",
+ "targetDir": "./目标目录",
+ "extensions": extensions_config
+ }
+
+ try:
+ pacc_json = project_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, ensure_ascii=False, indent=2), encoding='utf-8')
+
+ # Test validation with Unicode
+ results = validate_extension_directory(project_dir)
+
+ # Should handle Unicode without crashing
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results >= 0
+
+ except (OSError, UnicodeError):
+ pytest.skip("Filesystem doesn't support Unicode filenames")
+
+ def test_extremely_large_files_handling(self):
+ """Test handling of extremely large extension files."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ project_dir = temp_path / "large-files"
+ project_dir.mkdir()
+
+ # Create normal-sized file
+ normal_hook = project_dir / "normal-hook.json"
+ normal_hook.write_text(json.dumps({
+ "name": "normal-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create large file (1MB of JSON)
+ large_content = {
+ "name": "large-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Large hook for testing",
+ "large_data": "x" * (1024 * 1024) # 1MB of data
+ }
+
+ large_hook = project_dir / "large-hook.json"
+ large_hook.write_text(json.dumps(large_content))
+
+ # Test validation handles large files
+ start_time = time.time()
+ results = validate_extension_directory(project_dir)
+ end_time = time.time()
+
+ duration = end_time - start_time
+
+ # Should complete in reasonable time despite large files
+ assert duration < 30.0, f"Large file validation too slow: {duration:.2f}s"
+
+ # Should validate both files
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results >= 2
+
+
+if __name__ == "__main__":
+ # Run cross-feature integration tests
+ pytest.main([__file__, "-v", "--tb=short"])
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/integration/test_folder_structure_integration.py b/apps/pacc-cli/tests/integration/test_folder_structure_integration.py
new file mode 100644
index 0000000..4727667
--- /dev/null
+++ b/apps/pacc-cli/tests/integration/test_folder_structure_integration.py
@@ -0,0 +1,874 @@
+"""Integration tests for folder structure features - PACC-26 Testing Part.
+
+This comprehensive test suite validates folder structure features:
+1. targetDir configuration and behavior
+2. preserveStructure option functionality
+3. Backward compatibility with existing installations
+4. Security validations (path traversal prevention)
+5. Cross-platform compatibility
+
+Coordinates with Agent-1's implementation work.
+
+Related Issues:
+- PACC-26: Comprehensive testing and documentation (subtask PACC-36)
+- Folder structure implementation by Agent-1
+"""
+
+import json
+import pytest
+import os
+import shutil
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import Dict, Any, List
+from unittest.mock import patch, MagicMock
+
+from pacc.core.config_manager import ClaudeConfigManager
+from pacc.core.project_config import ProjectConfigManager
+from pacc.validators import validate_extension_directory
+from pacc.cli import PACCCli
+
+
+class TestFolderStructureConfiguration:
+ """Test targetDir configuration functionality."""
+
+ def test_target_dir_basic_configuration(self):
+ """Test basic targetDir configuration in pacc.json."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create source structure
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create pacc.json with targetDir configuration
+ pacc_config = {
+ "name": "folder-structure-test",
+ "version": "1.0.0",
+ "targetDir": "./custom-extensions",
+ "extensions": {
+ "hooks": [
+ {"name": "test-hook", "source": "./hooks/test-hook.json", "version": "1.0.0"}
+ ]
+ }
+ }
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Create hooks directory and file
+ hooks_dir = source_dir / "hooks"
+ hooks_dir.mkdir()
+ hook_file = hooks_dir / "test-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Test hook for targetDir"
+ }))
+
+ # Test configuration loading
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert config is not None
+ assert "targetDir" in config
+ assert config["targetDir"] == "./custom-extensions"
+
+ def test_target_dir_with_nested_structure(self):
+ """Test targetDir with nested directory structures."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create complex source structure
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create nested structure
+ nested_hooks = source_dir / "extensions" / "hooks" / "level1" / "level2"
+ nested_hooks.mkdir(parents=True)
+
+ hook_file = nested_hooks / "nested-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "nested-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": "Nested hook test"
+ }))
+
+ # Create pacc.json with nested targetDir
+ pacc_config = {
+ "name": "nested-structure-test",
+ "version": "1.0.0",
+ "targetDir": "./target/extensions/custom",
+ "extensions": {
+ "hooks": [
+ {
+ "name": "nested-hook",
+ "source": "./extensions/hooks/level1/level2/nested-hook.json",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Validate configuration
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert config["targetDir"] == "./target/extensions/custom"
+ assert len(config["extensions"]["hooks"]) == 1
+
+ def test_target_dir_path_normalization(self):
+ """Test targetDir path normalization across platforms."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Test various path formats
+ test_paths = [
+ "./custom-extensions",
+ "custom-extensions/",
+ "./custom-extensions/",
+ "custom-extensions",
+ "../project/extensions",
+ "./sub/dir/extensions"
+ ]
+
+ for target_path in test_paths:
+ pacc_config = {
+ "name": "path-normalization-test",
+ "version": "1.0.0",
+ "targetDir": target_path,
+ "extensions": {}
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test path normalization
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert "targetDir" in config
+ # Should normalize path without crashing
+ normalized_path = Path(config["targetDir"])
+ assert isinstance(normalized_path, Path)
+
+
+class TestPreserveStructureFeature:
+ """Test preserveStructure option functionality."""
+
+ def test_preserve_structure_enabled(self):
+ """Test preserveStructure: true maintains directory hierarchy."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create source with nested structure
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create nested extensions
+ nested_structure = [
+ "extensions/hooks/auth/pre-auth.json",
+ "extensions/hooks/tools/tool-validator.json",
+ "extensions/commands/user/profile.md",
+ "extensions/agents/system/monitor.md"
+ ]
+
+ extensions_list = {"hooks": [], "commands": [], "agents": []}
+
+ for rel_path in nested_structure:
+ full_path = source_dir / rel_path
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+
+ # Create appropriate content based on extension type
+ if "hooks" in rel_path:
+ content = {
+ "name": full_path.stem,
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "description": f"Hook from {rel_path}"
+ }
+ full_path.write_text(json.dumps(content, indent=2))
+ extensions_list["hooks"].append({
+ "name": full_path.stem,
+ "source": f"./{rel_path}",
+ "version": "1.0.0"
+ })
+ elif "commands" in rel_path:
+ content = f"""---
+name: {full_path.stem}
+description: Command from {rel_path}
+---
+
+# /{full_path.stem}
+
+Command content.
+"""
+ full_path.write_text(content)
+ extensions_list["commands"].append({
+ "name": full_path.stem,
+ "source": f"./{rel_path}",
+ "version": "1.0.0"
+ })
+ elif "agents" in rel_path:
+ content = f"""---
+name: {full_path.stem}
+description: Agent from {rel_path}
+tools: ["file-reader"]
+---
+
+Agent content.
+"""
+ full_path.write_text(content)
+ extensions_list["agents"].append({
+ "name": full_path.stem,
+ "source": f"./{rel_path}",
+ "version": "1.0.0"
+ })
+
+ # Create pacc.json with preserveStructure enabled
+ pacc_config = {
+ "name": "preserve-structure-test",
+ "version": "1.0.0",
+ "targetDir": "./custom-target",
+ "preserveStructure": True,
+ "extensions": extensions_list
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test configuration
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert config["preserveStructure"] is True
+ assert config["targetDir"] == "./custom-target"
+
+ # Verify all extensions are properly configured
+ assert len(config["extensions"]["hooks"]) == 2
+ assert len(config["extensions"]["commands"]) == 1
+ assert len(config["extensions"]["agents"]) == 1
+
+ def test_preserve_structure_disabled(self):
+ """Test preserveStructure: false flattens directory structure."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create same nested structure as above
+ nested_files = [
+ "deep/nested/hook1.json",
+ "very/deep/nested/hook2.json"
+ ]
+
+ hooks_list = []
+ for rel_path in nested_files:
+ full_path = source_dir / rel_path
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+
+ content = {
+ "name": full_path.stem,
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }
+ full_path.write_text(json.dumps(content, indent=2))
+ hooks_list.append({
+ "name": full_path.stem,
+ "source": f"./{rel_path}",
+ "version": "1.0.0"
+ })
+
+ # Create pacc.json with preserveStructure disabled
+ pacc_config = {
+ "name": "flatten-structure-test",
+ "version": "1.0.0",
+ "targetDir": "./flattened",
+ "preserveStructure": False,
+ "extensions": {"hooks": hooks_list}
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test configuration
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert config["preserveStructure"] is False
+ assert len(config["extensions"]["hooks"]) == 2
+
+ def test_preserve_structure_default_behavior(self):
+ """Test default behavior when preserveStructure is not specified."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create basic structure
+ hook_file = source_dir / "test-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create pacc.json WITHOUT preserveStructure field
+ pacc_config = {
+ "name": "default-behavior-test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [
+ {"name": "test-hook", "source": "./test-hook.json", "version": "1.0.0"}
+ ]
+ }
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test configuration
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ # Should have default value (likely False for backward compatibility)
+ preserve_structure = config.get("preserveStructure", False)
+ assert isinstance(preserve_structure, bool)
+
+
+class TestFolderStructureSecurityValidation:
+ """Test security validations for folder structure features."""
+
+ def test_path_traversal_prevention_target_dir(self):
+ """Test prevention of path traversal attacks in targetDir."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Test malicious targetDir values
+ malicious_paths = [
+ "../../../etc/passwd",
+ "..\\..\\..\\windows\\system32",
+ "/etc/passwd",
+ "C:\\Windows\\System32",
+ "../../sensitive/data",
+ "../outside-project"
+ ]
+
+ for malicious_path in malicious_paths:
+ pacc_config = {
+ "name": "security-test",
+ "version": "1.0.0",
+ "targetDir": malicious_path,
+ "extensions": {}
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test security validation
+ config_manager = ProjectConfigManager()
+
+ try:
+ config = config_manager.load_project_config(source_dir)
+
+ # Should either reject malicious path or sanitize it
+ if config and "targetDir" in config:
+ # If accepted, should be sanitized/normalized
+ target_path = Path(config["targetDir"])
+
+ # Should not allow absolute paths to system directories
+ assert not str(target_path).startswith("/etc")
+ assert not str(target_path).startswith("C:\\Windows")
+
+ except (ValueError, SecurityError, Exception) as e:
+ # Should raise security-related error
+ assert any(keyword in str(e).lower() for keyword in
+ ["security", "path", "invalid", "traversal"])
+
+ def test_source_path_traversal_prevention(self):
+ """Test prevention of path traversal in extension source paths."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Test malicious source paths
+ malicious_sources = [
+ "../../../etc/passwd",
+ "../../outside-project/malicious.json",
+ "/etc/shadow",
+ "..\\..\\windows\\system32\\evil.exe"
+ ]
+
+ for malicious_source in malicious_sources:
+ pacc_config = {
+ "name": "source-security-test",
+ "version": "1.0.0",
+ "extensions": {
+ "hooks": [
+ {
+ "name": "malicious-hook",
+ "source": malicious_source,
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test validation
+ try:
+ results = validate_extension_directory(source_dir)
+
+ # If validation proceeds, should handle missing files gracefully
+ # rather than attempting to access system files
+ if "hooks" in results:
+ for result in results["hooks"]:
+ # Should not successfully validate system files
+ assert not result.is_valid or "malicious" not in result.file_path
+
+ except (FileNotFoundError, PermissionError, SecurityError):
+ # Expected - should not access system files
+ pass
+
+ def test_symlink_security_handling(self):
+ """Test security handling of symbolic links in folder structures."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create legitimate file
+ legitimate_file = source_dir / "legitimate.json"
+ legitimate_file.write_text(json.dumps({
+ "name": "legitimate",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Create potentially dangerous symlink target outside project
+ outside_dir = temp_path / "outside"
+ outside_dir.mkdir()
+ dangerous_file = outside_dir / "dangerous.json"
+ dangerous_file.write_text(json.dumps({
+ "name": "dangerous",
+ "version": "1.0.0",
+ "events": ["PreToolUse"],
+ "malicious": "content"
+ }))
+
+ # Create symlink (if supported)
+ try:
+ if hasattr(os, 'symlink'):
+ symlink_path = source_dir / "symlink.json"
+ os.symlink(dangerous_file, symlink_path)
+
+ # Test validation handles symlinks securely
+ results = validate_extension_directory(source_dir)
+
+ # Should either reject symlinks or validate them securely
+ if "hooks" in results:
+ for result in results["hooks"]:
+ # Should not expose dangerous content through symlinks
+ if "symlink" in result.file_path:
+ # Either should be invalid or properly sandboxed
+ pass
+
+ except (OSError, NotImplementedError):
+ pytest.skip("Platform does not support symlinks")
+
+
+class TestFolderStructureBackwardCompatibility:
+ """Test backward compatibility with existing installations."""
+
+ def test_legacy_installation_compatibility(self):
+ """Test that new folder structure features don't break legacy installations."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create legacy-style Claude Code config directory
+ claude_config_dir = temp_path / ".claude"
+ claude_config_dir.mkdir()
+
+ # Create legacy config.json (without folder structure features)
+ legacy_config = {
+ "hooks": [
+ {
+ "name": "legacy-hook",
+ "path": str(temp_path / "legacy-hook.json"),
+ "version": "1.0.0"
+ }
+ ],
+ "commands": [
+ {
+ "name": "legacy-command",
+ "path": str(temp_path / "legacy-command.md"),
+ "version": "1.0.0"
+ }
+ ]
+ }
+
+ config_file = claude_config_dir / "config.json"
+ config_file.write_text(json.dumps(legacy_config, indent=2))
+
+ # Create legacy extension files
+ legacy_hook = temp_path / "legacy-hook.json"
+ legacy_hook.write_text(json.dumps({
+ "name": "legacy-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ legacy_command = temp_path / "legacy-command.md"
+ legacy_command.write_text("""---
+name: legacy-command
+---
+
+# /legacy-command
+
+Legacy command content.
+""")
+
+ # Test that legacy configuration still works
+ config_manager = ClaudeConfigManager()
+
+ try:
+ # Should be able to read legacy config without errors
+ config = config_manager.load_config(claude_config_dir)
+
+ # Should contain legacy entries
+ assert "hooks" in config or "commands" in config
+
+ except Exception as e:
+ # Should not break on legacy configurations
+ assert False, f"Legacy config caused error: {e}"
+
+ def test_migration_from_legacy_to_folder_structure(self):
+ """Test migration path from legacy to new folder structure."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create legacy setup
+ legacy_extensions = {
+ "hooks": [{"name": "old-hook", "path": "./old-hook.json", "version": "1.0.0"}],
+ "commands": [{"name": "old-command", "path": "./old-command.md", "version": "1.0.0"}]
+ }
+
+ # Create new pacc.json with folder structure features
+ new_config = {
+ "name": "migrated-project",
+ "version": "1.0.0",
+ "targetDir": "./modern-extensions",
+ "preserveStructure": True,
+ "extensions": {
+ "hooks": [
+ {"name": "new-hook", "source": "./hooks/new-hook.json", "version": "1.0.0"}
+ ],
+ "commands": [
+ {"name": "new-command", "source": "./commands/new-command.md", "version": "1.0.0"}
+ ]
+ }
+ }
+
+ pacc_json = temp_path / "pacc.json"
+ pacc_json.write_text(json.dumps(new_config, indent=2))
+
+ # Create corresponding files
+ hooks_dir = temp_path / "hooks"
+ hooks_dir.mkdir()
+ new_hook = hooks_dir / "new-hook.json"
+ new_hook.write_text(json.dumps({
+ "name": "new-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ commands_dir = temp_path / "commands"
+ commands_dir.mkdir()
+ new_command = commands_dir / "new-command.md"
+ new_command.write_text("""---
+name: new-command
+---
+
+# /new-command
+
+New command with folder structure.
+""")
+
+ # Test that new configuration loads properly
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(temp_path)
+
+ assert config["targetDir"] == "./modern-extensions"
+ assert config["preserveStructure"] is True
+ assert len(config["extensions"]["hooks"]) == 1
+ assert len(config["extensions"]["commands"]) == 1
+
+ def test_mixed_legacy_modern_compatibility(self):
+ """Test compatibility when both legacy and modern configurations exist."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create both legacy and modern configuration files
+
+ # Legacy .claude/config.json
+ claude_dir = temp_path / ".claude"
+ claude_dir.mkdir()
+ legacy_config = {
+ "hooks": [{"name": "legacy-hook", "path": "./legacy-hook.json", "version": "1.0.0"}]
+ }
+ (claude_dir / "config.json").write_text(json.dumps(legacy_config))
+
+ # Modern pacc.json
+ modern_config = {
+ "name": "mixed-project",
+ "version": "1.0.0",
+ "targetDir": "./modern",
+ "extensions": {
+ "commands": [{"name": "modern-command", "source": "./modern-command.md", "version": "1.0.0"}]
+ }
+ }
+ (temp_path / "pacc.json").write_text(json.dumps(modern_config))
+
+ # Create extension files
+ (temp_path / "legacy-hook.json").write_text(json.dumps({
+ "name": "legacy-hook", "version": "1.0.0", "events": ["PreToolUse"]
+ }))
+
+ (temp_path / "modern-command.md").write_text("""---
+name: modern-command
+---
+
+# /modern-command
+
+Modern command.
+""")
+
+ # Test that both configurations can coexist
+ # (Implementation may prefer one over the other, but should not crash)
+
+ try:
+ legacy_manager = ClaudeConfigManager()
+ modern_manager = ProjectConfigManager()
+
+ # Both should work without interfering
+ legacy_config_loaded = legacy_manager.load_config(claude_dir)
+ modern_config_loaded = modern_manager.load_project_config(temp_path)
+
+ # Should not cause conflicts
+ assert legacy_config_loaded is not None or modern_config_loaded is not None
+
+ except Exception as e:
+ assert False, f"Mixed configuration caused conflict: {e}"
+
+
+class TestFolderStructureCrossPlatform:
+ """Test cross-platform compatibility of folder structure features."""
+
+ def test_windows_path_separators(self):
+ """Test handling of Windows-style path separators."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create configuration with Windows-style paths
+ windows_config = {
+ "name": "windows-paths-test",
+ "version": "1.0.0",
+ "targetDir": ".\\windows\\style\\paths",
+ "preserveStructure": True,
+ "extensions": {
+ "hooks": [
+ {
+ "name": "windows-hook",
+ "source": ".\\hooks\\windows-hook.json",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(windows_config, indent=2))
+
+ # Create corresponding directory structure
+ hooks_dir = source_dir / "hooks"
+ hooks_dir.mkdir()
+ hook_file = hooks_dir / "windows-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "windows-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Test cross-platform path handling
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ # Should normalize paths for current platform
+ assert "targetDir" in config
+ target_path = Path(config["targetDir"])
+ assert isinstance(target_path, Path)
+
+ # Should find extension files regardless of path separator style
+ results = validate_extension_directory(source_dir)
+ if "hooks" in results:
+ assert len(results["hooks"]) > 0
+
+ def test_unix_path_separators(self):
+ """Test handling of Unix-style path separators."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create configuration with Unix-style paths
+ unix_config = {
+ "name": "unix-paths-test",
+ "version": "1.0.0",
+ "targetDir": "./unix/style/paths",
+ "preserveStructure": True,
+ "extensions": {
+ "commands": [
+ {
+ "name": "unix-command",
+ "source": "./commands/unix-command.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(unix_config, indent=2))
+
+ # Create corresponding structure
+ commands_dir = source_dir / "commands"
+ commands_dir.mkdir()
+ command_file = commands_dir / "unix-command.md"
+ command_file.write_text("""---
+name: unix-command
+---
+
+# /unix-command
+
+Unix-style command.
+""")
+
+ # Test path handling
+ config_manager = ProjectConfigManager()
+ config = config_manager.load_project_config(source_dir)
+
+ assert config["targetDir"] == "./unix/style/paths"
+
+ # Validate extensions
+ results = validate_extension_directory(source_dir)
+ if "commands" in results:
+ assert len(results["commands"]) > 0
+
+ def test_case_sensitive_filesystem_handling(self):
+ """Test handling of case-sensitive vs case-insensitive filesystems."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create files with different cases
+ test_files = [
+ ("UPPERCASE.json", "hooks"),
+ ("lowercase.json", "hooks"),
+ ("MixedCase.json", "hooks")
+ ]
+
+ hooks_dir = source_dir / "hooks"
+ hooks_dir.mkdir()
+
+ extensions_config = {"hooks": []}
+
+ for filename, ext_type in test_files:
+ file_path = hooks_dir / filename
+ file_path.write_text(json.dumps({
+ "name": filename.replace('.json', ''),
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ extensions_config["hooks"].append({
+ "name": filename.replace('.json', ''),
+ "source": f"./hooks/{filename}",
+ "version": "1.0.0"
+ })
+
+ # Create pacc.json
+ pacc_config = {
+ "name": "case-sensitivity-test",
+ "version": "1.0.0",
+ "extensions": extensions_config
+ }
+
+ pacc_json = source_dir / "pacc.json"
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Test validation
+ results = validate_extension_directory(source_dir)
+
+ # Should handle all files regardless of filesystem case sensitivity
+ if "hooks" in results:
+ assert len(results["hooks"]) == len(test_files)
+
+ def test_long_path_handling(self):
+ """Test handling of long file paths (Windows 260 char limit, etc.)."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ source_dir = temp_path / "source"
+ source_dir.mkdir()
+
+ # Create deeply nested directory structure
+ long_path_parts = ["very"] * 10 + ["long"] * 10 + ["directory"] * 5 + ["structure"]
+ deep_dir = source_dir
+
+ for part in long_path_parts:
+ deep_dir = deep_dir / part
+ try:
+ deep_dir.mkdir(exist_ok=True)
+ except OSError as e:
+ # Skip test if filesystem doesn't support long paths
+ if "path too long" in str(e).lower() or e.errno == 36: # ENAMETOOLONG
+ pytest.skip(f"Filesystem doesn't support long paths: {e}")
+ raise
+
+ # Create file in deep directory
+ long_path_file = deep_dir / "deep-hook.json"
+ try:
+ long_path_file.write_text(json.dumps({
+ "name": "deep-hook",
+ "version": "1.0.0",
+ "events": ["PreToolUse"]
+ }))
+
+ # Test validation with long paths
+ results = validate_extension_directory(source_dir)
+
+ # Should handle long paths gracefully
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results >= 0 # Should not crash
+
+ except OSError as e:
+ if "path too long" in str(e).lower():
+ pytest.skip(f"Filesystem doesn't support long paths: {e}")
+ raise
+
+
+if __name__ == "__main__":
+ # Run folder structure integration tests
+ pytest.main([__file__, "-v", "--tb=short"])
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/integration/test_s01_fixes_integration.py b/apps/pacc-cli/tests/integration/test_s01_fixes_integration.py
new file mode 100644
index 0000000..f442dd6
--- /dev/null
+++ b/apps/pacc-cli/tests/integration/test_s01_fixes_integration.py
@@ -0,0 +1,941 @@
+"""Integration tests for S01 fixes - PACC-26 Testing Part.
+
+This comprehensive test suite validates the S01 fixes end-to-end:
+1. Directory validation improvements
+2. Extension type detection hierarchy (pacc.json > directory > content)
+3. CLI validate command integration with all scenarios
+4. Cross-platform compatibility and edge cases
+
+Related Issues:
+- PACC-24: Extension detection hierarchy implementation
+- PACC-18: Fix slash command misclassification
+- PACC-26: Comprehensive testing and documentation
+"""
+
+import json
+import pytest
+import time
+import os
+import subprocess
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import List, Dict, Any
+from unittest.mock import patch, MagicMock
+
+from pacc.validators.utils import ExtensionDetector
+from pacc.validators.base import ValidationResult
+from pacc.validators import (
+ validate_extension_file,
+ validate_extension_directory,
+ ValidatorFactory,
+ ValidationRunner
+)
+from pacc.core.project_config import ProjectConfigManager
+from pacc.cli import PACCCli
+
+
+class TestS01DirectoryValidationIntegration:
+ """Test S01 directory validation improvements end-to-end."""
+
+ def test_complete_directory_validation_workflow(self):
+ """Test complete directory validation workflow with S01 improvements."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create complex directory structure
+ self._create_complex_test_structure(temp_path)
+
+ # Run directory validation
+ results = validate_extension_directory(temp_path)
+
+ # Verify all extension types are detected
+ assert "hooks" in results
+ assert "agents" in results
+ assert "commands" in results
+ assert "mcp" in results
+
+ # Verify results structure
+ assert isinstance(results["hooks"], list)
+ assert isinstance(results["agents"], list)
+ assert isinstance(results["commands"], list)
+ assert isinstance(results["mcp"], list)
+
+ # Verify specific files are validated
+ hook_files = [r.file_path for r in results["hooks"]]
+ agent_files = [r.file_path for r in results["agents"]]
+ command_files = [r.file_path for r in results["commands"]]
+
+ assert any("test-hook.json" in f for f in hook_files)
+ assert any("test-agent.md" in f for f in agent_files)
+ assert any("test-command.md" in f for f in command_files)
+
+ def test_nested_directory_validation_performance(self):
+ """Test validation performance with deeply nested directories."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create deeply nested structure
+ self._create_nested_test_structure(temp_path, depth=5)
+
+ start_time = time.time()
+ results = validate_extension_directory(temp_path)
+ end_time = time.time()
+
+ duration = end_time - start_time
+ total_files = sum(len(file_list) for file_list in results.values())
+
+ # Performance assertions
+ assert duration < 3.0, f"Validation took too long: {duration:.2f}s"
+ assert total_files > 10, "Should find multiple extension files"
+
+ # Verify results are valid
+ for extension_type, validation_results in results.items():
+ assert isinstance(validation_results, list)
+ for result in validation_results:
+ assert isinstance(result, ValidationResult)
+
+ def test_mixed_valid_invalid_directory_handling(self):
+ """Test directory validation with mix of valid and invalid files."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create mixed content
+ self._create_mixed_validity_structure(temp_path)
+
+ results = validate_extension_directory(temp_path)
+
+ # Should handle both valid and invalid files gracefully
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+ # Check that we have both valid and invalid results
+ all_results = []
+ for extension_results in results.values():
+ all_results.extend(extension_results)
+
+ valid_count = sum(1 for r in all_results if r.is_valid)
+ invalid_count = sum(1 for r in all_results if not r.is_valid)
+
+ assert valid_count > 0, "Should have some valid files"
+ assert invalid_count > 0, "Should have some invalid files"
+
+ def _create_complex_test_structure(self, base_path: Path):
+ """Create complex test directory structure."""
+ # Hooks
+ hooks_dir = base_path / "hooks"
+ hooks_dir.mkdir()
+ (hooks_dir / "test-hook.json").write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Agents
+ agents_dir = base_path / "agents"
+ agents_dir.mkdir()
+ (agents_dir / "test-agent.md").write_text("""---
+name: test-agent
+description: A test agent
+tools: ["file-reader"]
+---
+
+Test agent content.
+""")
+
+ # Commands
+ commands_dir = base_path / "commands"
+ commands_dir.mkdir()
+ (commands_dir / "test-command.md").write_text("""---
+name: test-command
+description: A test command
+---
+
+# /test-command
+
+Test command content.
+""")
+
+ # MCP servers
+ mcp_dir = base_path / "mcp"
+ mcp_dir.mkdir()
+ (mcp_dir / "test-server.json").write_text(json.dumps({
+ "name": "test-server",
+ "command": ["python", "server.py"],
+ "args": ["--port", "3000"]
+ }))
+
+ def _create_nested_test_structure(self, base_path: Path, depth: int):
+ """Create deeply nested test structure."""
+ current_path = base_path
+
+ for level in range(depth):
+ level_dir = current_path / f"level_{level}"
+ level_dir.mkdir()
+
+ # Add some files at each level
+ if level % 2 == 0: # Even levels get hooks
+ hook_file = level_dir / f"hook_level_{level}.json"
+ hook_file.write_text(json.dumps({
+ "name": f"hook-level-{level}",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+ else: # Odd levels get commands
+ command_file = level_dir / f"command_level_{level}.md"
+ command_file.write_text(f"""---
+name: command-level-{level}
+---
+
+# /command-level-{level}
+
+Command at level {level}.
+""")
+
+ current_path = level_dir
+
+ def _create_mixed_validity_structure(self, base_path: Path):
+ """Create structure with valid and invalid files."""
+ # Valid hook
+ valid_hook = base_path / "valid-hook.json"
+ valid_hook.write_text(json.dumps({
+ "name": "valid-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"],
+ "description": "Valid hook for testing"
+ }))
+
+ # Invalid hook (missing required fields)
+ invalid_hook = base_path / "invalid-hook.json"
+ invalid_hook.write_text(json.dumps({
+ "name": "invalid-hook"
+ # Missing version and events
+ }))
+
+ # Malformed JSON
+ malformed_hook = base_path / "malformed-hook.json"
+ malformed_hook.write_text('{"name": "malformed", "invalid": json}')
+
+ # Valid command
+ valid_command = base_path / "valid-command.md"
+ valid_command.write_text("""---
+name: valid-command
+---
+
+# /valid-command
+
+Valid command content.
+""")
+
+ # Invalid command (missing frontmatter)
+ invalid_command = base_path / "invalid-command.md"
+ invalid_command.write_text("Just plain text without proper structure.")
+
+
+class TestS01ExtensionDetectionHierarchyIntegration:
+ """Test S01 extension detection hierarchy integration."""
+
+ def test_pacc_json_highest_priority_integration(self):
+ """Test pacc.json declarations take highest priority in complete workflow."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create misleading directory structure
+ agents_dir = temp_path / "agents"
+ agents_dir.mkdir()
+
+ # File that looks like agent but is declared as command in pacc.json
+ misleading_file = agents_dir / "actually-command.md"
+ misleading_file.write_text("""---
+name: actually-command
+description: Looks like agent but is actually a command
+tools: ["file-reader"]
+permissions: ["read-files"]
+---
+
+This has agent keywords but should be detected as command due to pacc.json.
+""")
+
+ # Create pacc.json declaring it as command
+ pacc_config = {
+ "name": "test-hierarchy",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "actually-command",
+ "source": "./agents/actually-command.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+ (temp_path / "pacc.json").write_text(json.dumps(pacc_config, indent=2))
+
+ # Run complete validation workflow
+ results = validate_extension_directory(temp_path)
+
+ # Should be detected as command, not agent
+ assert "commands" in results
+ assert len(results["commands"]) > 0
+
+ # Should not be in agents
+ if "agents" in results:
+ agent_files = [r.file_path for r in results["agents"]]
+ assert not any("actually-command.md" in f for f in agent_files)
+
+ # Verify the specific file is in commands
+ command_files = [r.file_path for r in results["commands"]]
+ assert any("actually-command.md" in f for f in command_files)
+
+ def test_directory_structure_secondary_priority_integration(self):
+ """Test directory structure priority when no pacc.json exists."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create directory structure without pacc.json
+ hooks_dir = temp_path / "hooks"
+ hooks_dir.mkdir()
+
+ # File with agent-like content in hooks directory
+ hook_file = hooks_dir / "agent-like-hook.json"
+ hook_file.write_text(json.dumps({
+ "description": "Contains agent keywords: tool, permission, agent",
+ "name": "agent-like-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"],
+ "actions": ["validate_agent_tools"]
+ }))
+
+ # Run detection
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(hook_file)
+
+ # Should detect as hooks due to directory structure
+ assert detected_type == "hooks"
+
+ # Verify in validation workflow
+ results = validate_extension_directory(temp_path)
+ assert "hooks" in results
+
+ hook_files = [r.file_path for r in results["hooks"]]
+ assert any("agent-like-hook.json" in f for f in hook_files)
+
+ def test_content_keywords_fallback_integration(self):
+ """Test content keywords as fallback when no other signals exist."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # File outside any special directory with clear agent content
+ agent_file = temp_path / "clear-agent.md"
+ agent_file.write_text("""---
+name: clear-agent
+description: A clear agent example
+tools: ["calculator", "file-reader"]
+permissions: ["read-files", "execute"]
+---
+
+This is clearly an agent based on tools and permissions.
+""")
+
+ # Run detection
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(agent_file)
+
+ # Should detect as agents based on content
+ assert detected_type == "agents"
+
+ # Verify in validation workflow
+ results = validate_extension_directory(temp_path)
+
+ if "agents" in results:
+ agent_files = [r.file_path for r in results["agents"]]
+ assert any("clear-agent.md" in f for f in agent_files)
+
+ def test_slash_command_misclassification_fix_integration(self):
+ """Test fix for PACC-18: slash commands incorrectly classified as agents."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create commands directory
+ commands_dir = temp_path / "commands"
+ commands_dir.mkdir()
+
+ # Create slash command that could be confused as agent
+ slash_command = commands_dir / "agent-helper.md"
+ slash_command.write_text("""---
+name: agent-helper
+description: Helps with agent-like tasks
+---
+
+# /agent-helper
+
+This command provides agent-like assistance with tool validation and permissions.
+
+## Features
+- Tool integration support
+- Permission checking
+- Agent-style assistance
+
+Contains agent keywords but should be command due to directory structure.
+""")
+
+ # Run detection
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(slash_command)
+
+ # Should be detected as command (fixes PACC-18)
+ assert detected_type == "commands", "PACC-18 regression: slash command misclassified as agent"
+
+ # Verify in validation workflow
+ results = validate_extension_directory(temp_path)
+ assert "commands" in results
+
+ command_files = [r.file_path for r in results["commands"]]
+ assert any("agent-helper.md" in f for f in command_files)
+
+ def test_hierarchy_override_chain_integration(self):
+ """Test complete hierarchy: pacc.json > directory > content."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create test files that demonstrate the hierarchy
+ test_files = [
+ # File 1: pacc.json overrides directory and content
+ ("hooks/declared-as-command.json", {
+ "description": "In hooks dir with hook content but declared as command",
+ "name": "declared-as-command",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"] # Hook-like content
+ }),
+
+ # File 2: Directory overrides content (no pacc.json declaration)
+ ("commands/agent-like-command.md", """---
+name: agent-like-command
+description: Has agent keywords but in commands directory
+tools: ["calculator"]
+permissions: ["execute"]
+---
+
+# /agent-like-command
+
+Agent-like content but should be command due to directory.
+"""),
+
+ # File 3: Content fallback (no directory/pacc.json signals)
+ ("standalone-agent.md", """---
+name: standalone-agent
+description: Clear agent with tools and permissions
+tools: ["file-reader", "calculator"]
+permissions: ["read-files", "execute"]
+---
+
+Clear agent content with no other signals.
+""")
+ ]
+
+ # Create directory structure and files
+ for file_path, content in test_files:
+ full_path = temp_path / file_path
+ full_path.parent.mkdir(parents=True, exist_ok=True)
+
+ if isinstance(content, dict):
+ full_path.write_text(json.dumps(content, indent=2))
+ else:
+ full_path.write_text(content)
+
+ # Create pacc.json that overrides first file
+ pacc_config = {
+ "name": "hierarchy-test",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "declared-as-command",
+ "source": "./hooks/declared-as-command.json",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+ (temp_path / "pacc.json").write_text(json.dumps(pacc_config, indent=2))
+
+ # Test individual detection
+ detector = ExtensionDetector()
+
+ # File 1: Should be command due to pacc.json (highest priority)
+ file1_path = temp_path / "hooks/declared-as-command.json"
+ type1 = detector.detect_extension_type(file1_path, project_dir=temp_path)
+ assert type1 == "commands", "pacc.json should override directory structure"
+
+ # File 2: Should be command due to directory (secondary priority)
+ file2_path = temp_path / "commands/agent-like-command.md"
+ type2 = detector.detect_extension_type(file2_path)
+ assert type2 == "commands", "Directory structure should override content keywords"
+
+ # File 3: Should be agent due to content (fallback)
+ file3_path = temp_path / "standalone-agent.md"
+ type3 = detector.detect_extension_type(file3_path)
+ assert type3 == "agents", "Content keywords should be fallback method"
+
+ # Verify in complete validation workflow
+ results = validate_extension_directory(temp_path)
+
+ # All files should be correctly categorized
+ assert "commands" in results
+ assert len(results["commands"]) >= 2 # Files 1 and 2
+
+ if "agents" in results:
+ agent_files = [r.file_path for r in results["agents"]]
+ assert any("standalone-agent.md" in f for f in agent_files)
+
+
+class TestS01CLIValidateCommandIntegration:
+ """Test S01 CLI validate command integration with all scenarios."""
+
+ def test_cli_validate_single_file_integration(self):
+ """Test CLI validate command with single file."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create valid hook file
+ hook_file = temp_path / "test-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "test-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"],
+ "description": "Test hook for CLI validation"
+ }))
+
+ # Test CLI validate command
+ cli = PACCCli()
+
+ # Mock args for validate command
+ class MockArgs:
+ source = str(hook_file)
+ type = None
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+
+ # Should succeed (return 0)
+ assert result == 0
+
+ def test_cli_validate_directory_integration(self):
+ """Test CLI validate command with directory."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create test structure
+ self._create_cli_test_structure(temp_path)
+
+ # Test CLI validate command
+ cli = PACCCli()
+
+ class MockArgs:
+ source = str(temp_path)
+ type = None
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+
+ # Should succeed with mixed valid/invalid files
+ assert result in [0, 1] # May have warnings/errors but should not crash
+
+ def test_cli_validate_with_type_filter_integration(self):
+ """Test CLI validate command with specific type filter."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create mixed extension types
+ self._create_cli_test_structure(temp_path)
+
+ # Test with hooks filter
+ cli = PACCCli()
+
+ class MockArgs:
+ source = str(temp_path)
+ type = "hooks"
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+
+ # Should process only hooks
+ assert result in [0, 1] # Should not crash
+
+ def test_cli_validate_strict_mode_integration(self):
+ """Test CLI validate command in strict mode."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create hook with warning (missing description)
+ hook_file = temp_path / "warning-hook.json"
+ hook_file.write_text(json.dumps({
+ "name": "warning-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ # Missing description - should generate warning
+ }))
+
+ # Test in normal mode
+ cli = PACCCli()
+
+ class MockArgs:
+ source = str(hook_file)
+ type = None
+ strict = False
+
+ normal_result = cli.validate_command(MockArgs())
+
+ # Test in strict mode
+ class MockStrictArgs:
+ source = str(hook_file)
+ type = None
+ strict = True
+
+ strict_result = cli.validate_command(MockStrictArgs())
+
+ # Normal mode should succeed with warnings
+ # Strict mode should fail due to warnings
+ assert normal_result == 0
+ assert strict_result == 1 # Should fail in strict mode
+
+ def test_cli_validate_nonexistent_path_handling(self):
+ """Test CLI validate command with nonexistent path."""
+ cli = PACCCli()
+
+ class MockArgs:
+ source = "/nonexistent/path"
+ type = None
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+
+ # Should fail gracefully
+ assert result == 1
+
+ def test_cli_validate_error_handling_integration(self):
+ """Test CLI validate command error handling."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create file that will cause validation error
+ error_file = temp_path / "error-hook.json"
+ error_file.write_text('{"invalid": json, "syntax": error}')
+
+ cli = PACCCli()
+
+ class MockArgs:
+ source = str(error_file)
+ type = None
+ strict = False
+
+ result = cli.validate_command(MockArgs())
+
+ # Should fail gracefully with error
+ assert result == 1
+
+ def _create_cli_test_structure(self, base_path: Path):
+ """Create test structure for CLI testing."""
+ # Valid hook
+ valid_hook = base_path / "valid-hook.json"
+ valid_hook.write_text(json.dumps({
+ "name": "valid-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'valid hook executed'"],
+ "description": "Valid hook for CLI testing"
+ }))
+
+ # Hook with warning
+ warning_hook = base_path / "warning-hook.json"
+ warning_hook.write_text(json.dumps({
+ "name": "warning-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ # Missing description
+ }))
+
+ # Invalid hook
+ invalid_hook = base_path / "invalid-hook.json"
+ invalid_hook.write_text(json.dumps({
+ "name": "invalid-hook"
+ # Missing required fields
+ }))
+
+ # Valid command
+ commands_dir = base_path / "commands"
+ commands_dir.mkdir()
+ valid_command = commands_dir / "valid-command.md"
+ valid_command.write_text("""---
+name: valid-command
+description: Valid command
+---
+
+# /valid-command
+
+Valid command content.
+""")
+
+
+class TestS01CrossPlatformIntegration:
+ """Test S01 fixes work across different platforms and edge cases."""
+
+ def test_windows_path_handling_integration(self):
+ """Test S01 fixes handle Windows-style paths correctly."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create files with various naming patterns
+ test_files = [
+ "UPPERCASE.JSON",
+ "mixed-Case.json",
+ "with spaces.json",
+ "with.dots.json",
+ "with_underscores.json"
+ ]
+
+ for filename in test_files:
+ file_path = temp_path / filename
+ file_path.write_text(json.dumps({
+ "name": filename.split('.')[0],
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Run validation
+ results = validate_extension_directory(temp_path)
+
+ # Should handle all files regardless of naming convention
+ if "hooks" in results:
+ validated_count = len(results["hooks"])
+ assert validated_count >= len(test_files)
+
+ def test_unicode_filename_handling_integration(self):
+ """Test S01 fixes handle Unicode filenames correctly."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create files with Unicode names
+ unicode_files = [
+ "测试-hook.json", # Chinese
+ "тест-hook.json", # Russian
+ "テスト-hook.json", # Japanese
+ "émoji-🎉-hook.json" # Emoji
+ ]
+
+ created_files = []
+ for filename in unicode_files:
+ try:
+ file_path = temp_path / filename
+ file_path.write_text(json.dumps({
+ "name": "unicode-test",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+ created_files.append(filename)
+ except (OSError, UnicodeError):
+ # Skip if filesystem doesn't support Unicode
+ continue
+
+ if created_files:
+ # Run validation
+ results = validate_extension_directory(temp_path)
+
+ # Should handle Unicode files without crashing
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+ def test_deep_nesting_performance_integration(self):
+ """Test S01 fixes handle deep directory nesting efficiently."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create deeply nested structure (10 levels)
+ current_path = temp_path
+ for level in range(10):
+ level_dir = current_path / f"level_{level}"
+ level_dir.mkdir()
+
+ # Add hook file at each level
+ hook_file = level_dir / f"hook_level_{level}.json"
+ hook_file.write_text(json.dumps({
+ "name": f"hook-level-{level}",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ current_path = level_dir
+
+ # Time the validation
+ start_time = time.time()
+ results = validate_extension_directory(temp_path)
+ end_time = time.time()
+
+ duration = end_time - start_time
+
+ # Should complete in reasonable time
+ assert duration < 5.0, f"Deep nesting validation too slow: {duration:.2f}s"
+
+ # Should find files at all levels
+ if "hooks" in results:
+ assert len(results["hooks"]) >= 5 # Should find hooks at multiple levels
+
+ def test_symlink_handling_integration(self):
+ """Test S01 fixes handle symbolic links appropriately."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create original file
+ original_file = temp_path / "original-hook.json"
+ original_file.write_text(json.dumps({
+ "name": "original-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Create symlink (if supported on platform)
+ symlink_file = temp_path / "symlink-hook.json"
+ try:
+ if hasattr(os, 'symlink'):
+ os.symlink(original_file, symlink_file)
+
+ # Run validation
+ results = validate_extension_directory(temp_path)
+
+ # Should handle symlinks gracefully
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+ except (OSError, NotImplementedError):
+ # Skip if platform doesn't support symlinks
+ pytest.skip("Platform does not support symlinks")
+
+ def test_permission_denied_handling_integration(self):
+ """Test S01 fixes handle permission denied errors gracefully."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create accessible file
+ accessible_file = temp_path / "accessible-hook.json"
+ accessible_file.write_text(json.dumps({
+ "name": "accessible-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Create restricted directory (simulate permission error)
+ restricted_dir = temp_path / "restricted"
+ restricted_dir.mkdir()
+ restricted_file = restricted_dir / "restricted-hook.json"
+ restricted_file.write_text(json.dumps({
+ "name": "restricted-hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Mock permission error for restricted directory
+ original_glob = Path.glob
+
+ def mock_glob(self, pattern, **kwargs):
+ if "restricted" in str(self):
+ raise PermissionError("Permission denied")
+ return original_glob(self, pattern, **kwargs)
+
+ with patch.object(Path, 'glob', mock_glob):
+ # Should handle permission error gracefully
+ results = validate_extension_directory(temp_path)
+
+ # Should still find accessible files
+ total_results = sum(len(file_list) for file_list in results.values())
+ assert total_results > 0
+
+
+class TestS01PerformanceBenchmarks:
+ """Performance benchmarks for S01 fixes."""
+
+ def test_large_directory_validation_benchmark(self):
+ """Benchmark validation performance with large directories."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create large number of files (1000)
+ for i in range(1000):
+ file_path = temp_path / f"test_hook_{i:04d}.json"
+ file_path.write_text(json.dumps({
+ "name": f"test-hook-{i}",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+
+ # Benchmark validation
+ start_time = time.time()
+ results = validate_extension_directory(temp_path)
+ end_time = time.time()
+
+ duration = end_time - start_time
+ files_per_second = 1000 / duration if duration > 0 else float('inf')
+
+ print(f"\nPerformance Benchmark Results:")
+ print(f"- Files validated: 1000")
+ print(f"- Duration: {duration:.3f}s")
+ print(f"- Files/second: {files_per_second:.1f}")
+
+ # Performance targets
+ assert duration < 10.0, f"Validation too slow: {duration:.2f}s"
+ assert files_per_second > 50, f"Throughput too low: {files_per_second:.1f} files/s"
+
+ def test_extension_detection_benchmark(self):
+ """Benchmark extension type detection performance."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create test files for detection
+ test_files = []
+ for i in range(100):
+ file_path = temp_path / f"test_file_{i:03d}.json"
+ file_path.write_text(json.dumps({
+ "name": f"test-{i}",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'hook executed'"]
+ }))
+ test_files.append(file_path)
+
+ # Benchmark detection
+ detector = ExtensionDetector()
+
+ start_time = time.time()
+ for file_path in test_files:
+ detected_type = detector.detect_extension_type(file_path)
+ end_time = time.time()
+
+ duration = end_time - start_time
+ detections_per_second = len(test_files) / duration if duration > 0 else float('inf')
+
+ print(f"\nDetection Benchmark Results:")
+ print(f"- Files processed: {len(test_files)}")
+ print(f"- Duration: {duration:.3f}s")
+ print(f"- Detections/second: {detections_per_second:.1f}")
+
+ # Performance targets
+ assert duration < 2.0, f"Detection too slow: {duration:.2f}s"
+ assert detections_per_second > 100, f"Detection throughput too low: {detections_per_second:.1f}/s"
+
+
+if __name__ == "__main__":
+ # Run integration tests
+ pytest.main([__file__, "-v", "--tb=short"])
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/test_command_functionality.py b/apps/pacc-cli/tests/test_command_functionality.py
index 017564d..5181312 100644
--- a/apps/pacc-cli/tests/test_command_functionality.py
+++ b/apps/pacc-cli/tests/test_command_functionality.py
@@ -26,18 +26,8 @@ def sample_hook_file(self, temp_project_dir):
"name": "test-hook",
"description": "Test hook for validation",
"version": "1.0.0",
- "events": [
- {
- "type": "PreToolUse",
- "matcher": {"tool": "Bash"},
- "action": {
- "type": "Ask",
- "config": {
- "message": "About to run bash command: {{tool.command}}"
- }
- }
- }
- ]
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'about to run bash command'"]
}
hook_file = temp_project_dir / "test-hook.json"
@@ -117,6 +107,145 @@ def test_validate_invalid_file(self, temp_project_dir):
assert result.returncode == 1
assert "error" in result.stdout.lower() or "error" in result.stderr.lower()
+
+ def test_validate_directory_mixed_extensions(self, temp_project_dir):
+ """Test validate command with directory containing mixed extension types."""
+ # Create a directory with multiple extension types
+ ext_dir = temp_project_dir / "extensions"
+ ext_dir.mkdir()
+
+ # Create a valid hooks file
+ hooks_file = ext_dir / "test.hooks.json"
+ hooks_content = {
+ "name": "test-hook",
+ "description": "Test hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'installing'"]
+ }
+ with open(hooks_file, "w") as f:
+ json.dump(hooks_content, f)
+
+ # Create a valid MCP server config
+ mcp_file = ext_dir / "server.mcp.json"
+ mcp_content = {
+ "mcpServers": {
+ "test-server": {
+ "command": "python",
+ "args": ["server.py"]
+ }
+ }
+ }
+ with open(mcp_file, "w") as f:
+ json.dump(mcp_content, f)
+
+ # Create an agents file
+ agents_file = ext_dir / "agent.agent.md"
+ agents_content = """---
+name: Test Agent
+description: A test agent
+version: 1.0.0
+---
+
+This is a test agent for validation.
+"""
+ agents_file.write_text(agents_content)
+
+ # Validate the entire directory
+ result = self.run_pacc_command(["validate", str(ext_dir)])
+
+ assert result.returncode == 0
+ assert "Validation passed" in result.stdout or "✓" in result.stdout
+
+ def test_validate_directory_with_type_filter(self, temp_project_dir):
+ """Test validate command with directory and type filter."""
+ ext_dir = temp_project_dir / "extensions"
+ ext_dir.mkdir()
+
+ # Create hooks and MCP files
+ hooks_file = ext_dir / "test.hooks.json"
+ hooks_content = {
+ "name": "test-hook",
+ "description": "Test hook",
+ "version": "1.0.0",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'test command'"]
+ }
+ with open(hooks_file, "w") as f:
+ json.dump(hooks_content, f)
+
+ mcp_file = ext_dir / "server.mcp.json"
+ mcp_content = {"mcpServers": {"test": {"command": "python", "args": ["test.py"]}}}
+ with open(mcp_file, "w") as f:
+ json.dump(mcp_content, f)
+
+ # Validate only hooks extensions
+ result = self.run_pacc_command(["validate", str(ext_dir), "--type", "hooks"])
+
+ assert result.returncode == 0
+ assert "Validation passed" in result.stdout or "✓" in result.stdout
+
+ def test_validate_directory_empty(self, temp_project_dir):
+ """Test validate command with empty directory."""
+ empty_dir = temp_project_dir / "empty"
+ empty_dir.mkdir()
+
+ result = self.run_pacc_command(["validate", str(empty_dir)])
+
+ assert result.returncode == 1
+ assert "no valid extensions found" in result.stderr.lower()
+
+ def test_validate_directory_no_matching_type(self, temp_project_dir):
+ """Test validate command with directory but no matching type."""
+ ext_dir = temp_project_dir / "extensions"
+ ext_dir.mkdir()
+
+ # Create only hooks files
+ hooks_file = ext_dir / "test.hooks.json"
+ hooks_content = {
+ "name": "test-hook",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'test'"]
+ }
+ with open(hooks_file, "w") as f:
+ json.dump(hooks_content, f)
+
+ # Filter for agents type (which doesn't exist)
+ result = self.run_pacc_command(["validate", str(ext_dir), "--type", "agents"])
+
+ assert result.returncode == 1
+ assert "no valid extensions found" in result.stderr.lower()
+
+ def test_validate_directory_invalid_extensions(self, temp_project_dir):
+ """Test validate command with directory containing invalid extensions."""
+ ext_dir = temp_project_dir / "extensions"
+ ext_dir.mkdir()
+
+ # Create an invalid hooks file (valid JSON but missing required fields)
+ invalid_hooks = ext_dir / "invalid.hooks.json"
+ invalid_content = {
+ "name": "invalid-hook",
+ "description": "Invalid hook with PreToolUse but missing required fields"
+ # Contains "PreToolUse" keyword to be detected as hooks, but missing eventTypes and commands
+ }
+ with open(invalid_hooks, "w") as f:
+ json.dump(invalid_content, f)
+
+ # Create a valid hooks file too
+ valid_hooks = ext_dir / "valid.hooks.json"
+ hooks_content = {
+ "name": "valid-hook",
+ "eventTypes": ["PreToolUse"],
+ "commands": ["echo 'valid test'"]
+ }
+ with open(valid_hooks, "w") as f:
+ json.dump(hooks_content, f)
+
+ result = self.run_pacc_command(["validate", str(ext_dir)])
+
+ # Should fail due to invalid file, even though one file is valid
+ assert result.returncode == 1
+ assert "error" in result.stdout.lower() or "error" in result.stderr.lower()
def test_list_command_empty(self, temp_project_dir):
"""Test list command when no extensions are installed."""
diff --git a/apps/pacc-cli/tests/unit/test_extension_detection_hierarchy.py b/apps/pacc-cli/tests/unit/test_extension_detection_hierarchy.py
new file mode 100644
index 0000000..66b3be8
--- /dev/null
+++ b/apps/pacc-cli/tests/unit/test_extension_detection_hierarchy.py
@@ -0,0 +1,338 @@
+"""Unit tests for extension type detection hierarchy (PACC-24).
+
+This test suite validates that the detection logic follows the proper hierarchy:
+1. pacc.json declarations (highest priority)
+2. Directory structure (secondary signal)
+3. Content keywords (fallback only)
+
+This addresses the PACC-18 issue where slash commands were incorrectly classified as agents.
+"""
+
+import json
+import pytest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch, MagicMock
+
+from pacc.validators.utils import ExtensionDetector
+from pacc.core.project_config import ProjectConfigManager
+
+
+class TestExtensionDetectionHierarchy:
+ """Test extension type detection hierarchy implementation."""
+
+ def test_pacc_json_declarations_highest_priority(self):
+ """Test that pacc.json declarations take highest priority over content keywords."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create a file that looks like an agent by content
+ agent_looking_file = temp_path / "helper.md"
+ agent_looking_file.write_text("""---
+name: helper-agent
+description: A helper agent for tasks
+---
+
+This agent helps with tool usage and permissions.
+""")
+
+ # Create pacc.json that declares this file as a command
+ pacc_json = temp_path / "pacc.json"
+ pacc_config = {
+ "name": "test-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "helper",
+ "source": "./helper.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Mock ProjectConfigManager to return our config
+ mock_config_manager = MagicMock()
+ mock_config_manager.load_project_config.return_value = pacc_config
+
+ with patch('pacc.core.project_config.ProjectConfigManager', return_value=mock_config_manager):
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(agent_looking_file, project_dir=temp_path)
+
+ # Should detect as command despite agent-like content
+ assert detected_type == "commands", f"Expected 'commands' but got '{detected_type}'. pacc.json declarations should take highest priority."
+
+ def test_directory_structure_secondary_priority(self):
+ """Test that directory structure is used when no pacc.json declaration exists."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create commands directory structure
+ commands_dir = temp_path / "commands"
+ commands_dir.mkdir()
+
+ # Create a file that could be confused as agent by content
+ slash_command_file = commands_dir / "helper.md"
+ slash_command_file.write_text("""# /helper
+
+Helps users with agent-like functionality.
+
+## Description
+
+This command provides agent assistance but is actually a slash command.
+Contains keywords: tool, permission, agent.
+""")
+
+ # No pacc.json file exists
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(slash_command_file)
+
+ # Should detect as command due to directory structure
+ assert detected_type == "commands", f"Expected 'commands' but got '{detected_type}'. Directory structure should be secondary priority."
+
+ def test_content_keywords_fallback_only(self):
+ """Test that content keywords are only used as final fallback."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create file outside any special directory with clear agent content
+ clear_agent_file = temp_path / "agent.md"
+ clear_agent_file.write_text("""---
+name: clear-agent
+description: A clear agent example
+tools: ["file-reader", "calculator"]
+permissions: ["read-files", "execute"]
+---
+
+This is clearly an agent with tools and permissions.
+""")
+
+ # No pacc.json, no special directory
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(clear_agent_file)
+
+ # Should detect as agent based on content keywords (fallback)
+ assert detected_type == "agents", f"Expected 'agents' but got '{detected_type}'. Content keywords should be fallback method."
+
+ def test_slash_command_misclassification_fix(self):
+ """Test fix for PACC-18: slash commands incorrectly classified as agents."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create commands directory
+ commands_dir = temp_path / "commands"
+ commands_dir.mkdir()
+
+ # Create slash command that might be confused as agent
+ slash_command = commands_dir / "pacc-install.md"
+ slash_command.write_text("""---
+name: pacc-install
+description: Install extensions using PACC CLI tool
+---
+
+# /pacc:install
+
+Install Claude Code extensions with tool validation and permission checking.
+
+## Usage
+/pacc:install
+
+## Features
+- Tool integration
+- Permission validation
+- Agent-like assistance
+
+This command helps users with extension installation.
+""")
+
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(slash_command)
+
+ # Should be detected as command, not agent (fixes PACC-18)
+ assert detected_type == "commands", f"PACC-18 regression: Expected 'commands' but got '{detected_type}'. Slash commands should not be misclassified as agents."
+
+ def test_pacc_json_overrides_directory_structure(self):
+ """Test that pacc.json declarations override directory structure signals."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create agents directory
+ agents_dir = temp_path / "agents"
+ agents_dir.mkdir()
+
+ # Create file in agents directory
+ file_in_agents = agents_dir / "actual-command.md"
+ file_in_agents.write_text("""# /actual-command
+
+This is actually a command but placed in agents directory.
+""")
+
+ # Create pacc.json that correctly declares this as a command
+ pacc_json = temp_path / "pacc.json"
+ pacc_config = {
+ "name": "test-project",
+ "version": "1.0.0",
+ "extensions": {
+ "commands": [
+ {
+ "name": "actual-command",
+ "source": "./agents/actual-command.md",
+ "version": "1.0.0"
+ }
+ ]
+ }
+ }
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Mock ProjectConfigManager
+ mock_config_manager = MagicMock()
+ mock_config_manager.load_project_config.return_value = pacc_config
+
+ with patch('pacc.core.project_config.ProjectConfigManager', return_value=mock_config_manager):
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(file_in_agents, project_dir=temp_path)
+
+ # Should detect as command despite being in agents directory
+ assert detected_type == "commands", f"Expected 'commands' but got '{detected_type}'. pacc.json should override directory structure."
+
+ def test_directory_structure_overrides_content_keywords(self):
+ """Test that directory structure overrides content-based detection."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create hooks directory
+ hooks_dir = temp_path / "hooks"
+ hooks_dir.mkdir()
+
+ # Create file with agent-like content but in hooks directory
+ hook_file = hooks_dir / "agent-like.json"
+ hook_file.write_text("""{
+ "description": "This has agent keywords: tool, permission, agent assistance",
+ "hooks": [
+ {
+ "event": "PreToolUse",
+ "action": "validate_agent_permissions"
+ }
+ ]
+}""")
+
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(hook_file)
+
+ # Should detect as hooks due to directory structure, not agent due to content
+ assert detected_type == "hooks", f"Expected 'hooks' but got '{detected_type}'. Directory structure should override content keywords."
+
+ def test_ambiguous_case_with_fallback_logic(self):
+ """Test fallback logic handles ambiguous cases gracefully."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create file with mixed signals
+ ambiguous_file = temp_path / "mixed.md"
+ ambiguous_file.write_text("""---
+description: Contains both agent and command keywords
+---
+
+This file has agent, tool, permission keywords.
+But also has command, usage, slash patterns.
+
+Could be either type - ambiguous case.
+""")
+
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(ambiguous_file)
+
+ # Should return one of the valid types or None, but not crash
+ assert detected_type in [None, "agents", "commands"], f"Ambiguous detection returned unexpected type: '{detected_type}'"
+
+ def test_no_detection_signals_returns_none(self):
+ """Test that files with no detection signals return None."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create file with no clear signals
+ unknown_file = temp_path / "readme.txt"
+ unknown_file.write_text("This is just a regular text file with no extension-specific content.")
+
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(unknown_file)
+
+ assert detected_type is None, f"Expected None for unknown file type, but got '{detected_type}'"
+
+ def test_multiple_extensions_in_pacc_json(self):
+ """Test handling multiple extensions declared in pacc.json."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create multiple files
+ agent_file = temp_path / "agent.md"
+ agent_file.write_text("Agent content")
+
+ command_file = temp_path / "command.md"
+ command_file.write_text("Command content")
+
+ # Create pacc.json with multiple extensions
+ pacc_json = temp_path / "pacc.json"
+ pacc_config = {
+ "name": "multi-extension-project",
+ "version": "1.0.0",
+ "extensions": {
+ "agents": [
+ {"name": "agent", "source": "./agent.md", "version": "1.0.0"}
+ ],
+ "commands": [
+ {"name": "command", "source": "./command.md", "version": "1.0.0"}
+ ]
+ }
+ }
+ pacc_json.write_text(json.dumps(pacc_config, indent=2))
+
+ # Mock ProjectConfigManager
+ mock_config_manager = MagicMock()
+ mock_config_manager.load_project_config.return_value = pacc_config
+
+ with patch('pacc.core.project_config.ProjectConfigManager', return_value=mock_config_manager):
+ detector = ExtensionDetector()
+
+ agent_type = detector.detect_extension_type(agent_file, project_dir=temp_path)
+ command_type = detector.detect_extension_type(command_file, project_dir=temp_path)
+
+ assert agent_type == "agents", f"Agent file should be detected as 'agents', got '{agent_type}'"
+ assert command_type == "commands", f"Command file should be detected as 'commands', got '{command_type}'"
+
+ def test_project_config_integration(self):
+ """Test integration with ProjectConfigManager for pacc.json awareness."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create test file
+ test_file = temp_path / "test-extension.md"
+ test_file.write_text("Test content")
+
+ # Test with real ProjectConfigManager (no pacc.json)
+ detector = ExtensionDetector()
+ result = detector.detect_extension_type(test_file, project_dir=temp_path)
+
+ # Should handle missing pacc.json gracefully
+ assert result is None or result in ["agents", "commands", "hooks", "mcp"], f"Unexpected detection result: '{result}'"
+
+ def test_backwards_compatibility(self):
+ """Test that existing code without project_dir parameter still works."""
+ with TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ # Create commands directory structure (old detection method)
+ commands_dir = temp_path / "commands"
+ commands_dir.mkdir()
+
+ command_file = commands_dir / "test.md"
+ command_file.write_text("# /test\nSlash command content")
+
+ # Test old signature (without project_dir)
+ detector = ExtensionDetector()
+ detected_type = detector.detect_extension_type(command_file)
+
+ # Should still work with directory structure detection
+ assert detected_type == "commands", f"Backwards compatibility failed: expected 'commands', got '{detected_type}'"
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/unit/test_url_downloader.py b/apps/pacc-cli/tests/unit/test_url_downloader.py
index 60d0b55..eb88a60 100644
--- a/apps/pacc-cli/tests/unit/test_url_downloader.py
+++ b/apps/pacc-cli/tests/unit/test_url_downloader.py
@@ -170,16 +170,20 @@ async def test_download_size_limit_exceeded(self):
"""Test download fails when size limit is exceeded."""
large_size = self.downloader.max_file_size_bytes + 1000000 # 1MB over limit
- with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session:
+ with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session_class:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {'content-length': str(large_size)}
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
- mock_session.return_value.__aenter__ = AsyncMock()
- mock_session.return_value.__aexit__ = AsyncMock()
- mock_session.return_value.get.return_value = mock_response
+ # Setup mock session
+ mock_session = AsyncMock()
+ mock_session.get.return_value = mock_response
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+
+ mock_session_class.return_value = mock_session
with tempfile.TemporaryDirectory() as temp_dir:
dest_path = Path(temp_dir) / "large_file.zip"
@@ -197,7 +201,7 @@ async def test_download_with_progress_callback(self):
def progress_callback(progress: DownloadProgress):
progress_updates.append(progress.percentage)
- with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session:
+ with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session_class:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {'content-length': str(len(mock_response_data))}
@@ -205,14 +209,24 @@ def progress_callback(progress: DownloadProgress):
# Simulate chunked reading
chunk_size = 250
chunks = [mock_response_data[i:i+chunk_size] for i in range(0, len(mock_response_data), chunk_size)]
- chunks.append(b'') # End of stream
- mock_response.content.read = AsyncMock(side_effect=chunks)
+
+ # Mock the chunked content iteration
+ async def mock_iter_chunked(chunk_size_arg):
+ for chunk in chunks:
+ if chunk: # Only yield non-empty chunks
+ yield chunk
+
+ mock_response.content.iter_chunked = mock_iter_chunked
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
- mock_session.return_value.__aenter__ = AsyncMock()
- mock_session.return_value.__aexit__ = AsyncMock()
- mock_session.return_value.get.return_value = mock_response
+ # Setup mock session
+ mock_session = AsyncMock()
+ mock_session.get.return_value = mock_response
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+
+ mock_session_class.return_value = mock_session
with tempfile.TemporaryDirectory() as temp_dir:
dest_path = Path(temp_dir) / "progress_test.txt"
@@ -328,17 +342,26 @@ async def test_full_url_installation_workflow(self):
mock_zip_data = self._create_mock_zip(test_content)
- with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session:
+ with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session_class:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {'content-length': str(len(mock_zip_data))}
- mock_response.content.read = AsyncMock(side_effect=[mock_zip_data, b''])
+
+ # Mock the chunked content iteration
+ async def mock_iter_chunked(chunk_size):
+ yield mock_zip_data
+
+ mock_response.content.iter_chunked = mock_iter_chunked
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
- mock_session.return_value.__aenter__ = AsyncMock()
- mock_session.return_value.__aexit__ = AsyncMock()
- mock_session.return_value.get.return_value = mock_response
+ # Setup mock session
+ mock_session = AsyncMock()
+ mock_session.get.return_value = mock_response
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+
+ mock_session_class.return_value = mock_session
with tempfile.TemporaryDirectory() as temp_dir:
install_dir = Path(temp_dir) / "installed"
@@ -373,17 +396,26 @@ async def test_url_caching(self):
mock_data = b"cached content"
url = "https://example.com/cached.zip"
- with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session:
+ with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session_class:
mock_response = AsyncMock()
mock_response.status = 200
mock_response.headers = {'content-length': str(len(mock_data))}
- mock_response.content.read = AsyncMock(side_effect=[mock_data, b''])
+
+ # Mock the chunked content iteration
+ async def mock_iter_chunked(chunk_size):
+ yield mock_data
+
+ mock_response.content.iter_chunked = mock_iter_chunked
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
mock_response.__aexit__ = AsyncMock(return_value=None)
- mock_session.return_value.__aenter__ = AsyncMock()
- mock_session.return_value.__aexit__ = AsyncMock()
- mock_session.return_value.get.return_value = mock_response
+ # Setup mock session
+ mock_session = AsyncMock()
+ mock_session.get.return_value = mock_response
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+
+ mock_session_class.return_value = mock_session
with tempfile.TemporaryDirectory() as temp_dir:
dest_path1 = Path(temp_dir) / "download1.zip"
@@ -407,7 +439,7 @@ async def test_download_with_redirects(self):
"""Test downloading with HTTP redirects."""
final_data = b"final content"
- with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session:
+ with patch('pacc.core.url_downloader.aiohttp.ClientSession') as mock_session_class:
# Setup redirect responses
redirect_response = AsyncMock()
redirect_response.status = 302
@@ -418,13 +450,22 @@ async def test_download_with_redirects(self):
final_response = AsyncMock()
final_response.status = 200
final_response.headers = {'content-length': str(len(final_data))}
- final_response.content.read = AsyncMock(side_effect=[final_data, b''])
+
+ # Mock the chunked content iteration
+ async def mock_iter_chunked(chunk_size):
+ yield final_data
+
+ final_response.content.iter_chunked = mock_iter_chunked
final_response.__aenter__ = AsyncMock(return_value=final_response)
final_response.__aexit__ = AsyncMock(return_value=None)
- mock_session.return_value.__aenter__ = AsyncMock()
- mock_session.return_value.__aexit__ = AsyncMock()
- mock_session.return_value.get.side_effect = [redirect_response, final_response]
+ # Setup mock session
+ mock_session = AsyncMock()
+ mock_session.get.side_effect = [redirect_response, final_response]
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+ mock_session.__aexit__ = AsyncMock(return_value=None)
+
+ mock_session_class.return_value = mock_session
with tempfile.TemporaryDirectory() as temp_dir:
dest_path = Path(temp_dir) / "redirected.zip"
diff --git a/apps/pacc-cli/tests/unit/test_validator_utils.py b/apps/pacc-cli/tests/unit/test_validator_utils.py
new file mode 100644
index 0000000..f766728
--- /dev/null
+++ b/apps/pacc-cli/tests/unit/test_validator_utils.py
@@ -0,0 +1,381 @@
+"""Unit tests for pacc.validators.utils module."""
+
+import json
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+import pytest
+
+from pacc.validators.utils import (
+ ValidationRunner,
+ validate_extension_directory,
+ validate_extension_file
+)
+from pacc.validators.base import ValidationResult, ValidationError
+
+
+class TestValidationRunner:
+ """Test ValidationRunner class functionality."""
+
+ def test_init_default(self):
+ """Test ValidationRunner initialization with defaults."""
+ runner = ValidationRunner()
+
+ # Should have all validator types
+ expected_types = {"hooks", "mcp", "agents", "commands"}
+ assert set(runner.validators.keys()) == expected_types
+
+ def test_validate_directory_no_filter(self, temp_dir):
+ """Test directory validation without extension type filter."""
+ runner = ValidationRunner()
+
+ # Create test files for different extension types
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Create hooks extension
+ hooks_file = test_dir / "test.hooks.json"
+ hooks_data = {
+ "hooks": [
+ {
+ "event": "beforeInstall",
+ "script": "echo 'before install'"
+ }
+ ]
+ }
+ hooks_file.write_text(json.dumps(hooks_data))
+
+ # Create MCP server config
+ mcp_file = test_dir / "test.mcp.json"
+ mcp_data = {
+ "mcpServers": {
+ "test-server": {
+ "command": "python",
+ "args": ["test.py"]
+ }
+ }
+ }
+ mcp_file.write_text(json.dumps(mcp_data))
+
+ # Mock ExtensionDetector to return our files
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {
+ "hooks": [hooks_file],
+ "mcp": [mcp_file]
+ }
+
+ results = runner.validate_directory(test_dir)
+
+ # Should have results for both types
+ assert "hooks" in results
+ assert "mcp" in results
+ assert len(results) == 2
+
+ def test_validate_directory_with_filter(self, temp_dir):
+ """Test directory validation with extension type filter."""
+ runner = ValidationRunner()
+
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Create test files for different types
+ hooks_file = test_dir / "test.hooks.json"
+ hooks_data = {"hooks": [{"event": "beforeInstall", "script": "echo test"}]}
+ hooks_file.write_text(json.dumps(hooks_data))
+
+ mcp_file = test_dir / "test.mcp.json"
+ mcp_data = {"mcpServers": {"test": {"command": "python", "args": ["test.py"]}}}
+ mcp_file.write_text(json.dumps(mcp_data))
+
+ # Mock ExtensionDetector
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {
+ "hooks": [hooks_file],
+ "mcp": [mcp_file]
+ }
+
+ # Test filtering by hooks only
+ results = runner.validate_directory(test_dir, extension_type="hooks")
+
+ # Should only have hooks results
+ assert "hooks" in results
+ assert "mcp" not in results
+ assert len(results) == 1
+
+ def test_validate_directory_filter_nonexistent_type(self, temp_dir):
+ """Test directory validation with filter for non-existent extension type."""
+ runner = ValidationRunner()
+
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Mock ExtensionDetector to return some files
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {
+ "hooks": [temp_dir / "test.hooks.json"]
+ }
+
+ # Filter for type that doesn't exist in directory
+ results = runner.validate_directory(test_dir, extension_type="agents")
+
+ # Should return empty results
+ assert len(results) == 0
+
+ def test_validate_directory_filter_invalid_type(self, temp_dir):
+ """Test directory validation with invalid extension type filter."""
+ runner = ValidationRunner()
+
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Mock ExtensionDetector
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {
+ "hooks": [temp_dir / "test.hooks.json"]
+ }
+
+ # Filter for completely invalid type
+ results = runner.validate_directory(test_dir, extension_type="invalid")
+
+ # Should return empty results (no error, just filtered out)
+ assert len(results) == 0
+
+ def test_validate_directory_preserves_original_behavior(self, temp_dir):
+ """Test that filtering doesn't break the original behavior when no filter is applied."""
+ runner = ValidationRunner()
+
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Create multiple extension files
+ files_by_type = {
+ "hooks": [temp_dir / "test1.hooks.json", temp_dir / "test2.hooks.json"],
+ "mcp": [temp_dir / "test.mcp.json"],
+ "agents": [temp_dir / "test.agent.md"],
+ }
+
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = files_by_type
+
+ # Test without filter (original behavior)
+ results_no_filter = runner.validate_directory(test_dir)
+
+ # Test with None filter (should be same as no filter)
+ results_none_filter = runner.validate_directory(test_dir, extension_type=None)
+
+ # Both should be identical
+ assert results_no_filter.keys() == results_none_filter.keys()
+ assert len(results_no_filter) == 3
+ assert len(results_none_filter) == 3
+
+
+class TestValidateExtensionDirectory:
+ """Test validate_extension_directory function."""
+
+ def test_validate_extension_directory_no_filter(self, temp_dir):
+ """Test validate_extension_directory without filter."""
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ # Mock the ValidationRunner
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"hooks": [], "mcp": []}
+
+ results = validate_extension_directory(test_dir)
+
+ # Should call ValidationRunner.validate_directory with no extension_type
+ mock_runner.validate_directory.assert_called_once_with(test_dir, None)
+ assert results == {"hooks": [], "mcp": []}
+
+ def test_validate_extension_directory_with_filter(self, temp_dir):
+ """Test validate_extension_directory with extension type filter."""
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"hooks": []}
+
+ results = validate_extension_directory(test_dir, extension_type="hooks")
+
+ # Should call ValidationRunner.validate_directory with extension_type
+ mock_runner.validate_directory.assert_called_once_with(test_dir, "hooks")
+ assert results == {"hooks": []}
+
+ def test_validate_extension_directory_backward_compatibility(self, temp_dir):
+ """Test that existing code calling without extension_type still works."""
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"hooks": [], "mcp": []}
+
+ # This should work exactly as before (positional argument only)
+ results = validate_extension_directory(test_dir)
+
+ mock_runner.validate_directory.assert_called_once_with(test_dir, None)
+ assert results == {"hooks": [], "mcp": []}
+
+ def test_validate_extension_directory_pathlib_path(self, temp_dir):
+ """Test validate_extension_directory accepts pathlib Path objects."""
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"hooks": []}
+
+ # Test with pathlib Path
+ results = validate_extension_directory(test_dir, extension_type="hooks")
+
+ mock_runner.validate_directory.assert_called_once_with(test_dir, "hooks")
+ assert results == {"hooks": []}
+
+ def test_validate_extension_directory_string_path(self, temp_dir):
+ """Test validate_extension_directory accepts string paths."""
+ test_dir = temp_dir / "test_extensions"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"mcp": []}
+
+ # Test with string path
+ results = validate_extension_directory(str(test_dir), extension_type="mcp")
+
+ mock_runner.validate_directory.assert_called_once_with(str(test_dir), "mcp")
+ assert results == {"mcp": []}
+
+
+class TestValidationIntegrationWithFiltering:
+ """Integration tests for validation with the new filtering capability."""
+
+ def test_end_to_end_directory_validation_filtering(self, temp_dir):
+ """Test complete directory validation workflow with filtering."""
+ test_dir = temp_dir / "mixed_extensions"
+ test_dir.mkdir()
+
+ # Create test files
+ hooks_file = test_dir / "deploy.hooks.json"
+ hooks_file.write_text('{"hooks": [{"event": "beforeInstall", "script": "npm install"}]}')
+
+ mcp_file = test_dir / "server.mcp.json"
+ mcp_file.write_text('{"mcpServers": {"fs": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}}')
+
+ agents_file = test_dir / "test.agent.md"
+ agents_file.write_text("---\nname: Test Agent\n---\nTest content")
+
+ # Mock ExtensionDetector to control what files are found
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ # Set up the mock to return our test files
+ mock_detector.scan_directory.return_value = {
+ "hooks": [hooks_file],
+ "mcp": [mcp_file],
+ "agents": [agents_file]
+ }
+
+ # Test filtering for hooks only
+ hooks_results = validate_extension_directory(test_dir, extension_type="hooks")
+
+ # Should only contain hooks results
+ assert "hooks" in hooks_results
+ assert "mcp" not in hooks_results
+ assert "agents" not in hooks_results
+
+ # Test filtering for MCP only
+ mcp_results = validate_extension_directory(test_dir, extension_type="mcp")
+
+ # Should only contain MCP results
+ assert "mcp" in mcp_results
+ assert "hooks" not in mcp_results
+ assert "agents" not in mcp_results
+
+ # Test no filtering (should get all types found)
+ all_results = validate_extension_directory(test_dir)
+
+ # Should contain all extension types present
+ extension_types = set(all_results.keys())
+ assert "hooks" in extension_types
+ assert "mcp" in extension_types
+ assert "agents" in extension_types
+
+ def test_cli_integration_compatibility(self, temp_dir):
+ """Test that the fix works with the CLI usage pattern."""
+ # Simulate how the CLI calls the function
+ test_dir = temp_dir / "cli_test"
+ test_dir.mkdir()
+
+ # Create a hooks file
+ hooks_file = test_dir / "test.hooks.json"
+ hooks_data = {"hooks": [{"event": "beforeInstall", "script": "echo test"}]}
+ hooks_file.write_text(json.dumps(hooks_data))
+
+ # This simulates the CLI call pattern: validate_extension_directory(source_path, args.type)
+ extension_type = "hooks" # This would be args.type in the CLI
+
+ try:
+ # This should not raise an exception (was the original bug)
+ results = validate_extension_directory(test_dir, extension_type)
+
+ # The function should execute successfully
+ assert isinstance(results, dict)
+
+ # If hooks files are found, they should be in the results
+ if results:
+ assert extension_type in results or len(results) == 0
+
+ except TypeError as e:
+ pytest.fail(f"CLI integration failed with TypeError: {e}")
+
+
+class TestEdgeCasesAndErrorHandling:
+ """Test edge cases and error handling for the new functionality."""
+
+ def test_empty_extension_type_string(self, temp_dir):
+ """Test behavior with empty string as extension_type."""
+ test_dir = temp_dir / "empty_type_test"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {"hooks": []}
+
+ # Empty string should be treated as a filter (not None)
+ results = validate_extension_directory(test_dir, extension_type="")
+
+ # Should return empty results since "" is not a valid extension type
+ assert len(results) == 0
+
+ def test_none_extension_type_explicit(self, temp_dir):
+ """Test explicit None as extension_type parameter."""
+ test_dir = temp_dir / "none_type_test"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ValidationRunner') as mock_runner_class:
+ mock_runner = MagicMock()
+ mock_runner_class.return_value = mock_runner
+ mock_runner.validate_directory.return_value = {"hooks": [], "mcp": []}
+
+ # Explicit None should behave same as not providing the parameter
+ results = validate_extension_directory(test_dir, extension_type=None)
+
+ mock_runner.validate_directory.assert_called_once_with(test_dir, None)
+
+ def test_case_sensitivity_extension_type(self, temp_dir):
+ """Test that extension type filtering is case sensitive."""
+ test_dir = temp_dir / "case_test"
+ test_dir.mkdir()
+
+ with patch('pacc.validators.utils.ExtensionDetector') as mock_detector:
+ mock_detector.scan_directory.return_value = {"hooks": []}
+
+ # Test with wrong case - should not match
+ results = validate_extension_directory(test_dir, extension_type="HOOKS")
+
+ # Should not match due to case sensitivity
+ assert len(results) == 0
\ No newline at end of file
diff --git a/apps/pacc-cli/tests/unit/test_validators.py b/apps/pacc-cli/tests/unit/test_validators.py
index f95a57f..950f0e7 100644
--- a/apps/pacc-cli/tests/unit/test_validators.py
+++ b/apps/pacc-cli/tests/unit/test_validators.py
@@ -401,7 +401,7 @@ def test_validate_file_accessibility_os_error(self, temp_dir, mock_validator):
test_file = temp_dir / "test.txt"
test_file.write_text("content")
- with patch('pathlib.Path.stat', side_effect=OSError("Test OS error")):
+ with patch.object(test_file, 'stat', side_effect=OSError("Test OS error")):
error = mock_validator._validate_file_accessibility(test_file)
assert error is not None
@@ -603,11 +603,12 @@ def validate_single(self, file_path):
)
# Add different types of issues based on filename
- if "error" in file_path.name:
+ filename = file_path.stem # Get filename without extension
+ if filename.startswith("error_"):
result.add_error("CRITICAL_ERROR", "This file has critical errors")
- if "warning" in file_path.name:
+ elif filename.startswith("warning_"):
result.add_warning("MINOR_WARNING", "This file has warnings")
- if "info" in file_path.name:
+ elif filename.startswith("info_"):
result.add_info("INFO_MESSAGE", "This file has info messages")
return result