diff --git a/devflow/cli/commands/skills_discovery_command.py b/devflow/cli/commands/skills_discovery_command.py new file mode 100644 index 0000000..12d6532 --- /dev/null +++ b/devflow/cli/commands/skills_discovery_command.py @@ -0,0 +1,452 @@ +"""Implementation of 'daf skills' command for discovery and inspection.""" + +import json +from pathlib import Path +from typing import Optional, Dict, List, Tuple +import re + +import click +from rich.console import Console +from rich.table import Table +from rich.panel import Panel + +from devflow.config.loader import ConfigLoader +from devflow.utils.paths import get_claude_config_dir, get_cs_home + +console = Console() + + +@click.command() +@click.argument("skill_name", required=False) +@click.option("--json", "output_json", is_flag=True, help="Output in JSON format") +def skills(skill_name: Optional[str], output_json: bool) -> None: + """List and inspect available skills. + + When run without arguments, lists all available skills grouped by level. + When given a skill name, shows detailed information about that skill. + + \b + Examples: + # List all skills + daf skills + + # Inspect specific skill + daf skills daf-cli + + # JSON output + daf skills --json + daf skills daf-cli --json + """ + config_loader = ConfigLoader() + config = config_loader.load_config() + + # Get workspace path if available + workspace_path = None + if config and config.repos and config.repos.workspaces: + # Use default workspace or first workspace + default_workspace = next( + (ws for ws in config.repos.workspaces if ws.name == config.repos.last_used_workspace), + None + ) or config.repos.workspaces[0] if config.repos.workspaces else None + if default_workspace: + workspace_path = default_workspace.path + + # Discover all skills + skills_by_level = _discover_all_skills(workspace_path) + + if skill_name: + # Inspect specific skill + _inspect_skill(skill_name, skills_by_level, output_json) + else: + # List all skills + _list_all_skills(skills_by_level, output_json) + + +def _discover_all_skills(workspace_path: Optional[str] = None) -> Dict[str, List[Dict]]: + """Discover all skills grouped by level. + + Returns: + Dict mapping level name to list of skill info dicts + """ + skills_by_level = { + "user": [], + "workspace": [], + "hierarchical": [], + "project": [] + } + + # 1. User-level skills: ~/.claude/skills/ + claude_config = get_claude_config_dir() + user_skills_dir = claude_config / "skills" + if user_skills_dir.exists(): + skills_by_level["user"] = _discover_skills_in_dir(user_skills_dir, "user") + + # 2. Workspace-level skills: /.claude/skills/ + if workspace_path: + workspace_skills_dir = Path(workspace_path).expanduser().resolve() / ".claude" / "skills" + if workspace_skills_dir.exists(): + skills_by_level["workspace"] = _discover_skills_in_dir(workspace_skills_dir, "workspace") + + # 3. Hierarchical skills: $DEVAIFLOW_HOME/.claude/skills/ + cs_home = get_cs_home() + hierarchical_skills_dir = cs_home / ".claude" / "skills" + if hierarchical_skills_dir.exists(): + skills_by_level["hierarchical"] = _discover_skills_in_dir(hierarchical_skills_dir, "hierarchical") + + # 4. Project-level skills: /.claude/skills/ + # Note: We check current directory for project-level skills + project_skills_dir = Path.cwd() / ".claude" / "skills" + if project_skills_dir.exists(): + skills_by_level["project"] = _discover_skills_in_dir(project_skills_dir, "project") + + return skills_by_level + + +def _discover_skills_in_dir(skills_dir: Path, level: str) -> List[Dict]: + """Discover all skills in a directory. + + Returns: + List of skill info dicts with keys: name, description, level, location, file_path, frontmatter + """ + skills = [] + + for skill_path in sorted(skills_dir.iterdir()): + if not skill_path.is_dir(): + continue + + skill_file = skill_path / "SKILL.md" + if not skill_file.exists(): + continue + + # Parse skill file + frontmatter, description = _parse_skill_file(skill_file) + + # Extract description from frontmatter or first non-empty line + # Clean control characters from description + skill_description = frontmatter.get("description", "") + if not skill_description: + skill_description = description + + # Remove control characters from description for clean JSON output + skill_description = skill_description.replace("\n", " ").replace("\r", " ").replace("\t", " ") + + skills.append({ + "name": skill_path.name, + "description": skill_description, + "level": level, + "location": str(skills_dir), + "file_path": str(skill_file), + "frontmatter": frontmatter + }) + + return skills + + +def _parse_skill_file(skill_file: Path) -> Tuple[Dict, str]: + """Parse a SKILL.md file and extract frontmatter and description. + + Returns: + Tuple of (frontmatter_dict, first_line_description) + """ + frontmatter = {} + description = "" + + try: + content = skill_file.read_text(encoding="utf-8") + + # Check for YAML frontmatter + if content.startswith("---\n"): + # Extract frontmatter + parts = content.split("---\n", 2) + if len(parts) >= 3: + frontmatter_text = parts[1] + body = parts[2] + + # Parse YAML frontmatter + try: + import yaml + frontmatter = yaml.safe_load(frontmatter_text) or {} + except ImportError: + # yaml not available, parse manually + for line in frontmatter_text.split("\n"): + if ":" in line: + key, value = line.split(":", 1) + frontmatter[key.strip()] = value.strip() + except Exception: + # If YAML parsing fails, parse manually + for line in frontmatter_text.split("\n"): + if ":" in line: + key, value = line.split(":", 1) + frontmatter[key.strip()] = value.strip() + else: + body = content + else: + body = content + + # Extract first non-empty line as description fallback + for line in body.split("\n"): + line = line.strip() + if line and not line.startswith("#"): + # Replace newlines and tabs with spaces for single-line description + description = line.replace("\n", " ").replace("\t", " ").replace("\r", " ")[:100] + break + + except Exception as e: + console.print(f"[yellow]Warning:[/yellow] Failed to parse {skill_file}: {e}", err=True) + + return frontmatter, description + + +def _list_all_skills(skills_by_level: Dict[str, List[Dict]], output_json: bool) -> None: + """List all skills grouped by level.""" + if output_json: + _list_skills_json(skills_by_level) + else: + _list_skills_table(skills_by_level) + + +def _list_skills_json(skills_by_level: Dict[str, List[Dict]]) -> None: + """Output skills list in JSON format.""" + # Calculate totals + level_counts = {level: len(skills) for level, skills in skills_by_level.items()} + total_skills = sum(level_counts.values()) + + output = { + "skills": [], + "total": total_skills, + "levels": level_counts + } + + # Flatten skills list + for level, skills in skills_by_level.items(): + for skill in skills: + # Sanitize description for JSON (remove control characters) + description = skill["description"].replace("\n", " ").replace("\r", " ").replace("\t", " ") + + output["skills"].append({ + "name": skill["name"], + "description": description, + "level": skill["level"], + "location": skill["location"], + "file_path": skill["file_path"], + "frontmatter": skill["frontmatter"] + }) + + # Sort by name + output["skills"].sort(key=lambda s: s["name"]) + + # Use print() instead of console.print() to avoid Rich wrapping the JSON + print(json.dumps(output, indent=2)) + + +def _list_skills_table(skills_by_level: Dict[str, List[Dict]]) -> None: + """Output skills list in table format.""" + console.print("\n[bold cyan]Available Skills (sorted by name)[/bold cyan]\n") + + # Collect all skills for sorted display + all_skills = [] + for level, skills in skills_by_level.items(): + all_skills.extend(skills) + + # Sort by name + all_skills.sort(key=lambda s: s["name"]) + + # Group by level for display + level_display = { + "user": f"User-level ({get_claude_config_dir() / 'skills'}):", + "workspace": "Workspace-level (/.claude/skills/):", + "hierarchical": f"Hierarchical ({get_cs_home() / '.claude/skills'}):", + "project": "Project-level (/.claude/skills/):" + } + + # Display skills grouped by level + for level_key in ["user", "workspace", "hierarchical", "project"]: + level_skills = [s for s in all_skills if s["level"] == level_key] + + if level_skills: + console.print(f"\n[bold]{level_display[level_key]}[/bold]") + for skill in level_skills: + console.print(f" [cyan]• {skill['name']}[/cyan] - {skill['description']}") + + # Show total count + total = len(all_skills) + level_counts = {level: len([s for s in all_skills if s["level"] == level]) for level in ["user", "workspace", "hierarchical", "project"]} + + console.print(f"\n[bold]Total: {total} skills[/bold]") + console.print(f"[dim] User: {level_counts['user']}, Workspace: {level_counts['workspace']}, " + f"Hierarchical: {level_counts['hierarchical']}, Project: {level_counts['project']}[/dim]\n") + + +def _inspect_skill(skill_name: str, skills_by_level: Dict[str, List[Dict]], output_json: bool) -> None: + """Inspect a specific skill.""" + # Find the skill (search all levels) + found_skills = [] + for level, skills in skills_by_level.items(): + for skill in skills: + if skill["name"] == skill_name: + found_skills.append(skill) + + if not found_skills: + if output_json: + # Use print() instead of console.print() to avoid Rich wrapping the JSON + print(json.dumps({"error": f"Skill '{skill_name}' not found"}, indent=2)) + else: + console.print(f"[red]✗[/red] Skill '{skill_name}' not found") + console.print(f"\n[dim]Run 'daf skills' to see all available skills[/dim]") + return + + if len(found_skills) > 1: + # Multiple skills found at different levels + if output_json: + _inspect_skill_json_multiple(found_skills) + else: + _inspect_skill_table_multiple(found_skills) + else: + # Single skill found + if output_json: + _inspect_skill_json(found_skills[0]) + else: + _inspect_skill_table(found_skills[0]) + + +def _inspect_skill_json(skill: Dict) -> None: + """Output skill details in JSON format.""" + # Read full content + content_preview = "" + try: + full_content = Path(skill["file_path"]).read_text(encoding="utf-8") + # Skip frontmatter for preview + if full_content.startswith("---\n"): + parts = full_content.split("---\n", 2) + if len(parts) >= 3: + content_preview = parts[2][:500] # First 500 chars + else: + content_preview = full_content[:500] + else: + content_preview = full_content[:500] + except Exception: + content_preview = "" + + # Sanitize description for JSON + description = skill["description"].replace("\n", " ").replace("\r", " ").replace("\t", " ") + + output = { + "name": skill["name"], + "description": description, + "level": skill["level"], + "location": skill["location"], + "file_path": skill["file_path"], + "frontmatter": skill["frontmatter"], + "content_preview": content_preview + } + + # Use print() instead of console.print() to avoid Rich wrapping the JSON + print(json.dumps(output, indent=2)) + + +def _inspect_skill_json_multiple(skills: List[Dict]) -> None: + """Output multiple skill instances in JSON format.""" + output = { + "name": skills[0]["name"], + "found_at_levels": len(skills), + "instances": [] + } + + for skill in skills: + # Read content preview + content_preview = "" + try: + full_content = Path(skill["file_path"]).read_text(encoding="utf-8") + if full_content.startswith("---\n"): + parts = full_content.split("---\n", 2) + if len(parts) >= 3: + content_preview = parts[2][:500] + else: + content_preview = full_content[:500] + else: + content_preview = full_content[:500] + except Exception: + content_preview = "" + + # Sanitize description for JSON + description = skill["description"].replace("\n", " ").replace("\r", " ").replace("\t", " ") + + output["instances"].append({ + "level": skill["level"], + "location": skill["location"], + "file_path": skill["file_path"], + "description": description, + "frontmatter": skill["frontmatter"], + "content_preview": content_preview + }) + + # Use print() instead of console.print() to avoid Rich wrapping the JSON + print(json.dumps(output, indent=2)) + + +def _inspect_skill_table(skill: Dict) -> None: + """Output skill details in table format.""" + console.print(f"\n[bold cyan]Skill: {skill['name']}[/bold cyan]\n") + console.print(f"[bold]Location:[/bold] {skill['location']}") + console.print(f"[bold]File:[/bold] {skill['file_path']}") + console.print(f"[bold]Level:[/bold] {skill['level']}") + + # Display frontmatter + if skill["frontmatter"]: + console.print(f"\n[bold]Frontmatter:[/bold]") + for key, value in skill["frontmatter"].items(): + console.print(f" [cyan]{key}:[/cyan] {value}") + + # Display content preview + try: + full_content = Path(skill["file_path"]).read_text(encoding="utf-8") + + # Skip frontmatter + if full_content.startswith("---\n"): + parts = full_content.split("---\n", 2) + if len(parts) >= 3: + content = parts[2] + else: + content = full_content + else: + content = full_content + + # Show first ~20 lines + lines = content.split("\n") + preview_lines = lines[:20] + + console.print(f"\n[bold]Content preview:[/bold]") + console.print("[dim]" + "-" * 60 + "[/dim]") + for line in preview_lines: + console.print(line) + + if len(lines) > 20: + console.print(f"[dim]... ({len(lines) - 20} more lines)[/dim]") + console.print("[dim]" + "-" * 60 + "[/dim]") + + console.print(f"\n[dim]To view full content: cat {skill['file_path']}[/dim]\n") + + except Exception as e: + console.print(f"\n[yellow]Warning:[/yellow] Could not read file content: {e}\n") + + +def _inspect_skill_table_multiple(skills: List[Dict]) -> None: + """Output multiple skill instances in table format.""" + console.print(f"\n[bold cyan]Skill: {skills[0]['name']}[/bold cyan]") + console.print(f"[yellow]⚠[/yellow] Found at {len(skills)} levels (precedence: Project > Hierarchical > Workspace > User)\n") + + # Show each instance + for i, skill in enumerate(skills, 1): + console.print(f"\n[bold]Instance #{i} ({skill['level']} level):[/bold]") + console.print(f" [bold]Location:[/bold] {skill['location']}") + console.print(f" [bold]File:[/bold] {skill['file_path']}") + + if skill["frontmatter"]: + console.print(f" [bold]Frontmatter:[/bold]") + for key, value in skill["frontmatter"].items(): + console.print(f" [cyan]{key}:[/cyan] {value}") + + console.print(f"\n[dim]To view full content of a specific instance:[/dim]") + for skill in skills: + console.print(f"[dim] cat {skill['file_path']}[/dim]") + console.print() diff --git a/devflow/cli/main.py b/devflow/cli/main.py index a3d1414..daecb8c 100644 --- a/devflow/cli/main.py +++ b/devflow/cli/main.py @@ -3732,6 +3732,10 @@ def purge_mock_data_cmd(ctx: click.Context, force: bool) -> None: from devflow.cli.commands.skills_command import assets cli.add_command(assets) +# Add skills command (discovery and inspection of available skills) +from devflow.cli.commands.skills_discovery_command import skills +cli.add_command(skills) + @cli.command() @click.option("--dry-run", is_flag=True, help="Show what would be upgraded without actually upgrading") diff --git a/devflow/cli_skills/daf-cli/SKILL.md b/devflow/cli_skills/daf-cli/SKILL.md index 6c5c24a..4c9ad68 100644 --- a/devflow/cli_skills/daf-cli/SKILL.md +++ b/devflow/cli_skills/daf-cli/SKILL.md @@ -43,6 +43,11 @@ user-invocable: false - View JIRA fields: `daf config show --fields` - Refresh fields: `daf config refresh-jira-fields` +### Skills Discovery +- List all available skills: `daf skills` +- Inspect specific skill: `daf skills ` +- JSON output: `daf skills --json` + ### Field Intelligence **See `daf-jira-fields` skill** for JIRA field mapping and validation rules. diff --git a/devflow/cli_skills/daf-help/SKILL.md b/devflow/cli_skills/daf-help/SKILL.md index a88e954..85cdfe8 100644 --- a/devflow/cli_skills/daf-help/SKILL.md +++ b/devflow/cli_skills/daf-help/SKILL.md @@ -36,6 +36,8 @@ daf jira update PROJ-12345 --priority Major # Update ticket **Configuration:** ```bash daf config show # View current configuration +daf skills # List all available skills +daf skills # Inspect specific skill ``` **Getting Help:** diff --git a/tests/test_skills_discovery_command.py b/tests/test_skills_discovery_command.py new file mode 100644 index 0000000..eb9c648 --- /dev/null +++ b/tests/test_skills_discovery_command.py @@ -0,0 +1,331 @@ +"""Tests for daf skills command (discovery and inspection).""" + +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from click.testing import CliRunner + +from devflow.cli.commands.skills_discovery_command import ( + _discover_all_skills, + _discover_skills_in_dir, + _parse_skill_file, + _list_skills_json, + _list_skills_table, + _inspect_skill, + skills +) + + +@pytest.fixture +def mock_skill_dir(tmp_path): + """Create a mock skill directory with sample skills.""" + skill_dir = tmp_path / "skills" + skill_dir.mkdir() + + # Create a skill with frontmatter + skill1 = skill_dir / "daf-test" + skill1.mkdir() + skill1_file = skill1 / "SKILL.md" + skill1_file.write_text("""--- +name: daf-test +description: Test skill for unit tests +user-invocable: true +--- + +# Test Skill + +This is a test skill. +""") + + # Create a skill without frontmatter + skill2 = skill_dir / "test-cli" + skill2.mkdir() + skill2_file = skill2 / "SKILL.md" + skill2_file.write_text("""# Test CLI Skill + +A skill without frontmatter. +""") + + # Create a non-skill directory (no SKILL.md) + non_skill = skill_dir / "not-a-skill" + non_skill.mkdir() + + return skill_dir + + +def test_parse_skill_file_with_frontmatter(tmp_path): + """Test parsing a skill file with YAML frontmatter.""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text("""--- +name: test-skill +description: Test description +user-invocable: true +--- + +# Test Skill + +Content here. +""") + + frontmatter, description = _parse_skill_file(skill_file) + + assert frontmatter["name"] == "test-skill" + assert frontmatter["description"] == "Test description" + assert frontmatter["user-invocable"] == "true" or frontmatter["user-invocable"] is True + assert "Test Skill" in description or "Content here" in description + + +def test_parse_skill_file_without_frontmatter(tmp_path): + """Test parsing a skill file without frontmatter.""" + skill_file = tmp_path / "SKILL.md" + skill_file.write_text("""# Simple Skill + +This is a simple skill without frontmatter. +""") + + frontmatter, description = _parse_skill_file(skill_file) + + assert frontmatter == {} or len(frontmatter) == 0 + assert "simple skill" in description.lower() + + +def test_discover_skills_in_dir(mock_skill_dir): + """Test discovering skills in a directory.""" + skills = _discover_skills_in_dir(mock_skill_dir, "user") + + assert len(skills) == 2 # Should find 2 skills (not-a-skill has no SKILL.md) + + skill_names = [s["name"] for s in skills] + assert "daf-test" in skill_names + assert "test-cli" in skill_names + + # Verify skill with frontmatter + daf_test = next(s for s in skills if s["name"] == "daf-test") + assert daf_test["description"] == "Test skill for unit tests" + assert daf_test["level"] == "user" + assert "frontmatter" in daf_test + assert daf_test["frontmatter"]["name"] == "daf-test" + + +def test_discover_skills_in_dir_empty(tmp_path): + """Test discovering skills in an empty directory.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + skills = _discover_skills_in_dir(empty_dir, "user") + + assert skills == [] + + +def test_discover_all_skills_user_level(monkeypatch, mock_skill_dir): + """Test discovering skills at user level.""" + with patch('devflow.cli.commands.skills_discovery_command.get_claude_config_dir', return_value=mock_skill_dir.parent): + with patch('devflow.cli.commands.skills_discovery_command.get_cs_home', return_value=Path("/nonexistent")): + # Rename mock_skill_dir to match expected user skills path + user_skills = mock_skill_dir.parent / "skills" + if mock_skill_dir != user_skills: + mock_skill_dir.rename(user_skills) + + skills_by_level = _discover_all_skills(workspace_path=None) + + assert "user" in skills_by_level + assert len(skills_by_level["user"]) == 2 + assert len(skills_by_level["workspace"]) == 0 + assert len(skills_by_level["hierarchical"]) == 0 + assert len(skills_by_level["project"]) == 0 + + +def test_list_skills_json_output(monkeypatch, mock_skill_dir, capsys): + """Test JSON output for skills list.""" + skills_by_level = { + "user": _discover_skills_in_dir(mock_skill_dir, "user"), + "workspace": [], + "hierarchical": [], + "project": [] + } + + _list_skills_json(skills_by_level) + + # Capture stdout + captured = capsys.readouterr() + + # Verify JSON structure + output = json.loads(captured.out) + assert "skills" in output + assert "total" in output + assert "levels" in output + assert output["total"] == 2 + assert len(output["skills"]) == 2 + + # Verify skills are sorted by name + skill_names = [s["name"] for s in output["skills"]] + assert skill_names == sorted(skill_names) + + +def test_list_skills_table_output(monkeypatch, mock_skill_dir): + """Test table output for skills list.""" + skills_by_level = { + "user": _discover_skills_in_dir(mock_skill_dir, "user"), + "workspace": [], + "hierarchical": [], + "project": [] + } + + with patch('devflow.cli.commands.skills_discovery_command.console') as mock_console: + with patch('devflow.cli.commands.skills_discovery_command.get_claude_config_dir', return_value=mock_skill_dir.parent): + with patch('devflow.cli.commands.skills_discovery_command.get_cs_home', return_value=Path("/nonexistent")): + _list_skills_table(skills_by_level) + + # Verify console.print was called (table was displayed) + assert mock_console.print.called + # Verify user-level skills were mentioned + calls = [str(call) for call in mock_console.print.call_args_list] + output_text = " ".join(calls) + assert "daf-test" in output_text or "test-cli" in output_text + + +def test_inspect_skill_found(monkeypatch, mock_skill_dir): + """Test inspecting a skill that exists.""" + skills_by_level = { + "user": _discover_skills_in_dir(mock_skill_dir, "user"), + "workspace": [], + "hierarchical": [], + "project": [] + } + + with patch('devflow.cli.commands.skills_discovery_command.console') as mock_console: + _inspect_skill("daf-test", skills_by_level, output_json=False) + + # Verify skill details were printed + assert mock_console.print.called + calls = [str(call) for call in mock_console.print.call_args_list] + output_text = " ".join(calls) + assert "daf-test" in output_text + assert "Test skill for unit tests" in output_text or "Test Skill" in output_text + + +def test_inspect_skill_not_found(monkeypatch): + """Test inspecting a skill that doesn't exist.""" + skills_by_level = { + "user": [], + "workspace": [], + "hierarchical": [], + "project": [] + } + + with patch('devflow.cli.commands.skills_discovery_command.console') as mock_console: + _inspect_skill("nonexistent-skill", skills_by_level, output_json=False) + + # Verify error message was printed + assert mock_console.print.called + calls = [str(call) for call in mock_console.print.call_args_list] + output_text = " ".join(calls) + assert "not found" in output_text.lower() + + +def test_inspect_skill_json_output(monkeypatch, mock_skill_dir, capsys): + """Test JSON output for skill inspection.""" + skills_by_level = { + "user": _discover_skills_in_dir(mock_skill_dir, "user"), + "workspace": [], + "hierarchical": [], + "project": [] + } + + _inspect_skill("daf-test", skills_by_level, output_json=True) + + # Capture stdout + captured = capsys.readouterr() + + # Verify JSON structure + output = json.loads(captured.out) + assert output["name"] == "daf-test" + assert output["description"] == "Test skill for unit tests" + assert output["level"] == "user" + assert "file_path" in output + assert "frontmatter" in output + assert "content_preview" in output + + +def test_inspect_skill_multiple_levels(monkeypatch, mock_skill_dir): + """Test inspecting a skill that exists at multiple levels.""" + # Create same skill at different levels + skills_by_level = { + "user": _discover_skills_in_dir(mock_skill_dir, "user"), + "hierarchical": _discover_skills_in_dir(mock_skill_dir, "hierarchical"), # Duplicate + "workspace": [], + "project": [] + } + + with patch('devflow.cli.commands.skills_discovery_command.console') as mock_console: + _inspect_skill("daf-test", skills_by_level, output_json=False) + + # Verify multiple instances were mentioned + assert mock_console.print.called + calls = [str(call) for call in mock_console.print.call_args_list] + output_text = " ".join(calls) + assert "levels" in output_text.lower() or "instance" in output_text.lower() + + +def test_skills_command_list_mode(monkeypatch): + """Test skills command in list mode (no arguments).""" + runner = CliRunner() + mock_config = MagicMock() + mock_config.repos.workspaces = [] + + with patch('devflow.cli.commands.skills_discovery_command.ConfigLoader') as mock_loader_class: + with patch('devflow.cli.commands.skills_discovery_command._discover_all_skills', return_value={"user": [], "workspace": [], "hierarchical": [], "project": []}): + with patch('devflow.cli.commands.skills_discovery_command._list_all_skills') as mock_list: + mock_loader = MagicMock() + mock_loader.load_config.return_value = mock_config + mock_loader_class.return_value = mock_loader + + result = runner.invoke(skills, []) + + # Verify list function was called + assert result.exit_code == 0 + mock_list.assert_called_once() + + +def test_skills_command_inspect_mode(monkeypatch): + """Test skills command in inspect mode (with skill name).""" + runner = CliRunner() + mock_config = MagicMock() + mock_config.repos.workspaces = [] + + with patch('devflow.cli.commands.skills_discovery_command.ConfigLoader') as mock_loader_class: + with patch('devflow.cli.commands.skills_discovery_command._discover_all_skills', return_value={"user": [], "workspace": [], "hierarchical": [], "project": []}): + with patch('devflow.cli.commands.skills_discovery_command._inspect_skill') as mock_inspect: + mock_loader = MagicMock() + mock_loader.load_config.return_value = mock_config + mock_loader_class.return_value = mock_loader + + result = runner.invoke(skills, ["test-skill"]) + + # Verify inspect function was called + assert result.exit_code == 0 + mock_inspect.assert_called_once_with("test-skill", {"user": [], "workspace": [], "hierarchical": [], "project": []}, False) + + +def test_skills_command_json_mode(monkeypatch): + """Test skills command with JSON output.""" + runner = CliRunner() + mock_config = MagicMock() + mock_config.repos.workspaces = [] + + with patch('devflow.cli.commands.skills_discovery_command.ConfigLoader') as mock_loader_class: + with patch('devflow.cli.commands.skills_discovery_command._discover_all_skills', return_value={"user": [], "workspace": [], "hierarchical": [], "project": []}): + with patch('devflow.cli.commands.skills_discovery_command._list_all_skills') as mock_list: + mock_loader = MagicMock() + mock_loader.load_config.return_value = mock_config + mock_loader_class.return_value = mock_loader + + result = runner.invoke(skills, ["--json"]) + + # Verify JSON flag was passed + assert result.exit_code == 0 + mock_list.assert_called_once() + args, kwargs = mock_list.call_args + assert args[1] is True # output_json parameter