diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23a3a7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/.megamemory/knowledge.db b/.megamemory/knowledge.db new file mode 100644 index 0000000..0a06b00 Binary files /dev/null and b/.megamemory/knowledge.db differ diff --git a/.megamemory/knowledge.db-shm b/.megamemory/knowledge.db-shm new file mode 100644 index 0000000..24376d4 Binary files /dev/null and b/.megamemory/knowledge.db-shm differ diff --git a/.megamemory/knowledge.db-wal b/.megamemory/knowledge.db-wal new file mode 100644 index 0000000..af8d9fe Binary files /dev/null and b/.megamemory/knowledge.db-wal differ diff --git a/README.md b/README.md index 0be329b..7d55da4 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,7 @@ A zero-dependency Python script that converts your skills directory into a Hiera ### Step 1: Run the Setup Script Download and run `setup.py`. It automatically categorizes your skills into expert domains (e.g., `ai-ml`, `security`, `frontend`, `automation`) using a keyword heuristic engine. -By default, the script targets OpenCode. You can specify Claude Code using the `--agent` flag: - -**For OpenCode:** +**For OpenCode (default):** ```bash python setup.py # Targets: ~/.config/opencode/skills @@ -98,6 +96,28 @@ python setup.py --agent claude # Targets: ~/.claude/skills # Vault: ~/.skillpointer-vault ``` + +**Custom Directories:** +```bash +python setup.py --skill-dir ~/.agents/skills --vault-dir ~/.skillpointer-vault +``` + +**Preview Changes (Dry Run):** +```bash +python setup.py --dry-run +``` + +### CLI Options + +| Option | Description | +|--------|-------------| +| `--agent {opencode,claude}` | Target AI agent (default: opencode) | +| `--skill-dir PATH` | Custom skills directory (overrides --agent) | +| `--vault-dir PATH` | Custom vault directory (overrides --agent) | +| `--dry-run` | Preview changes without making them | +| `--version` | Show version number | +| `--help` | Show help message | + *(Note for Claude Code: The `.skillpointer-vault` directory is intentionally prefixed with a dot so Claude's aggressive file scanner natively skips it during Level 1 context hydration).* ### Step 2: Test It! @@ -173,6 +193,31 @@ No custom tools, no plugins, no API calls. Just smart organization of native ski --- +## ๐Ÿงช Development & Testing + +Run the test suite: + +```bash +# Create virtual environment +uv venv .venv && source .venv/bin/activate + +# Install dev dependencies +uv pip install pytest + +# Run tests +python -m pytest tests/ -v +``` + +Run linting and type checking: + +```bash +uv pip install ruff mypy +ruff check setup.py +mypy setup.py +``` + +--- +
View Star History
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d351a96 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[project] +name = "skillpointer" +version = "1.1.0" +description = "Infinite AI Context. Zero Token Tax." +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "SkillPointer Contributors"} +] +keywords = ["ai", "skills", "context", "opencode", "claude"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "ruff>=0.1.0", + "mypy>=1.0.0", +] + +[project.scripts] +skillpointer = "setup:main" + +[project.urls] +Homepage = "https://github.com/blacksiders/SkillPointer" +Repository = "https://github.com/blacksiders/SkillPointer" +Issues = "https://github.com/blacksiders/SkillPointer/issues" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py310" +line-length = 100 +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] + +[tool.ruff.per-file-ignores] +"tests/*" = ["B011"] + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["pytest"] +ignore_missing_imports = true diff --git a/setup.py b/setup.py index 7441f8e..ba0e515 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,33 @@ -import os +#!/usr/bin/env python3 +""" +SkillPointer - Infinite AI Context. Zero Token Tax. + +A tool that reorganizes AI agent skills into a hierarchical pointer architecture +to minimize startup token costs while maintaining full skill accessibility. +""" + +from __future__ import annotations + import shutil import sys -import time +from dataclasses import dataclass, field from pathlib import Path +from typing import Any + +__version__ = "1.1.0" +__all__ = ["main", "get_category_for_skill"] # ========================================== -# ๐ŸŽฏ SkillPointer -# Infinite Context. Zero Token Tax. +# Constants # ========================================== +PROGRESS_LOG_THRESHOLD = 5 +BATCH_PROGRESS_INTERVAL = 50 + class Colors: + """ANSI color codes for terminal output.""" + HEADER = "\033[95m" BLUE = "\033[94m" CYAN = "\033[96m" @@ -21,15 +38,34 @@ class Colors: BOLD = "\033[1m" -# Global configuration state -CONFIG = { - "agent_name": "OpenCode", - "active_skills_dir": Path.home() / ".config" / "opencode" / "skills", - "hidden_library_dir": Path.home() / ".opencode-skill-libraries", -} +# ========================================== +# Configuration +# ========================================== + + +@dataclass +class Config: + """Runtime configuration for SkillPointer.""" + + agent_name: str = "OpenCode" + active_skills_dir: Path = field( + default_factory=lambda: Path.home() / ".config" / "opencode" / "skills" + ) + hidden_library_dir: Path = field( + default_factory=lambda: Path.home() / ".opencode-skill-libraries" + ) + dry_run: bool = False + + def validate(self) -> bool: + """Validate configuration paths exist and are directories.""" + return self.active_skills_dir.is_dir() + + +# ========================================== +# Domain Heuristics +# ========================================== -# Advanced Heuristic Engine for Universal Categorization -DOMAIN_HEURISTICS = { +DOMAIN_HEURISTICS: dict[str, list[str]] = { "security": [ "attack", "injection", @@ -50,6 +86,8 @@ class Colors: "security", "exploit", "encryption", + "vibesec", + "vibe-security", ], "code-review": [ "code-review", @@ -336,6 +374,29 @@ class Colors: "data-", "etl", ], + "debug": [ + "debug", + "debugging", + "breakpoint", + "logger", + "logging", + "trace", + "profiler", + "profiling", + "devtools", + "inspector", + "monitor", + "troubleshoot", + "diagnostic", + "error-tracking", + "sentry", + "datadog", + "newrelic", + "bugtracking", + "bug", + "bug-hunter", + "hunter", + ], "education": [ "learning", "course", @@ -391,6 +452,7 @@ class Colors: "kotlin", "algorithm", "data-structure", + "mago", ], "prompt-engineering": [ "system-prompt", @@ -440,46 +502,108 @@ class Colors: "pip", "extension", "plugin", + "find-skills", + ], + "documentation": [ + "documentation", + "docstring", + "doc-", + "code-documenter", + "documenter", + "readme", + "api-docs", + "swagger", + "openapi", + "jsdoc", + "sphinx", + ], + "wordpress": [ + "wordpress", + "wp-", + "generatepress", + "generateblocks", + "gutenberg", + "woocommerce", + "acf", + "wp-cli", ], } +# Precomputed keyword lookup for O(1) category matching +_KEYWORD_LOOKUP: dict[str, str] = { + kw: cat for cat, kws in DOMAIN_HEURISTICS.items() for kw in kws +} -def print_banner(): - print(f"\n{Colors.BOLD}{Colors.CYAN} ๐ŸŽฏ SkillPointer {Colors.ENDC}") + +# ========================================== +# Core Functions +# ========================================== + + +def print_banner() -> None: + """Display the SkillPointer banner.""" + print( + f"\n{Colors.BOLD}{Colors.CYAN} ๐ŸŽฏ SkillPointer v{__version__}{Colors.ENDC}" + ) print(f"{Colors.BLUE} Infinite Context. Zero Token Tax.\n{Colors.ENDC}") def get_category_for_skill(skill_name: str) -> str: - # Detect exact search within quotes + """ + Determine the category for a skill based on its name. + + Uses keyword matching against the DOMAIN_HEURISTICS dictionary. + Supports exact matching (when name is wrapped in quotes) and + substring matching (default). + + Args: + skill_name: The name of the skill folder. + + Returns: + The category name, or "_uncategorized" if no match found. + """ exact_match = False if skill_name.startswith('"') and skill_name.endswith('"'): exact_match = True - name_lower = skill_name[1:-1].strip().lower().replace("_", "-").replace(" ", "-") + name_lower = ( + skill_name[1:-1].strip().lower().replace("_", "-").replace(" ", "-") + ) else: name_lower = skill_name.lower().replace("_", "-") + # Special handling for PR-related code reviews has_pr_term = any( term in name_lower for term in ("pr-review", "pull-request", "merge-request") ) if "review" in name_lower and has_pr_term: return "code-review" - for category, keywords in DOMAIN_HEURISTICS.items(): - if exact_match: - # Exact match: the full term must match one of the keywords - if name_lower in keywords: - return category - else: - # Substring match: a known keyword is contained within the term - if any(kw in name_lower for kw in keywords): + if exact_match: + # Exact match: the full term must be in our keyword lookup + if name_lower in _KEYWORD_LOOKUP: + return _KEYWORD_LOOKUP[name_lower] + else: + # Substring match: check if any keyword is contained in the name + for keyword, category in _KEYWORD_LOOKUP.items(): + if keyword in name_lower: return category + return "_uncategorized" -def setup_directories(): - agent_name = CONFIG["agent_name"] - active_skills_dir = CONFIG["active_skills_dir"] - hidden_library_dir = CONFIG["hidden_library_dir"] +def setup_directories(config: Config) -> bool: + """ + Validate and create necessary directories. + + Args: + config: The runtime configuration. + + Returns: + True if setup successful, False otherwise. + """ + agent_name = config.agent_name + active_skills_dir = config.active_skills_dir + hidden_library_dir = config.hidden_library_dir if not active_skills_dir.exists(): print( @@ -490,21 +614,60 @@ def setup_directories(): ) return False - hidden_library_dir.mkdir(parents=True, exist_ok=True) + if not active_skills_dir.is_dir(): + print( + f"{Colors.FAIL}โœ– Error: {active_skills_dir} is not a directory.{Colors.ENDC}" + ) + return False + + try: + hidden_library_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + print( + f"{Colors.FAIL}โœ– Error: Permission denied creating vault at {hidden_library_dir}{Colors.ENDC}" + ) + return False + except OSError as e: + print( + f"{Colors.FAIL}โœ– Error: Could not create vault directory: {e}{Colors.ENDC}" + ) + return False + return True -def migrate_skills(): - active_skills_dir = CONFIG["active_skills_dir"] - hidden_library_dir = CONFIG["hidden_library_dir"] +def migrate_skills(config: Config) -> dict[str, int]: + """ + Move skills from active directory to categorized vault. + + Args: + config: The runtime configuration. - print(f"{Colors.BOLD}๐Ÿ“ฆ Phase 1: Analyzing and Migrating Skills...{Colors.ENDC}\n") + Returns: + Dictionary mapping category names to counts of migrated skills. + """ + active_skills_dir = config.active_skills_dir + hidden_library_dir = config.hidden_library_dir + dry_run = config.dry_run - category_counts = {} + action = "Would migrate" if dry_run else "Migrating" + print(f"{Colors.BOLD}๐Ÿ“ฆ Phase 1: {action} Skills...{Colors.ENDC}\n") + + category_counts: dict[str, int] = {} moved_count = 0 pointer_count = 0 + errors: list[str] = [] + + # Snapshot directory listing to avoid modification during iteration + try: + folders = list(active_skills_dir.iterdir()) + except PermissionError: + print( + f"{Colors.FAIL}โœ– Error: Permission denied reading {active_skills_dir}{Colors.ENDC}" + ) + return category_counts - for folder in list(active_skills_dir.iterdir()): + for folder in folders: if not folder.is_dir(): continue @@ -513,46 +676,109 @@ def migrate_skills(): pointer_count += 1 continue - # Ignore empty folders - if not any(folder.iterdir()): + # Ignore empty folders (with error handling) + try: + if not any(folder.iterdir()): + continue + except PermissionError: + print( + f"{Colors.WARNING}โš  Skipping {folder.name} (permission denied){Colors.ENDC}" + ) continue category = get_category_for_skill(folder.name) cat_dir = hidden_library_dir / category - cat_dir.mkdir(parents=True, exist_ok=True) + + if not dry_run: + try: + cat_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + errors.append(f"Permission denied creating {cat_dir}") + continue dest = cat_dir / folder.name + if dest.exists(): - shutil.rmtree(dest) + if dry_run: + print( + f"{Colors.WARNING} โš  Would replace existing: {dest}{Colors.ENDC}" + ) + else: + try: + if dest.is_symlink() or dest.is_file(): + dest.unlink() + else: + shutil.rmtree(dest) + except (OSError, PermissionError) as e: + errors.append(f"Could not remove {dest}: {e}") + continue - shutil.move(str(folder), str(cat_dir)) + if dry_run: + print( + f"{Colors.GREEN} โ†ณ Would map '{folder.name}' โž” {category}/{Colors.ENDC}" + ) + else: + try: + shutil.move(str(folder), str(dest)) + except (OSError, shutil.Error) as e: + errors.append(f"Could not move {folder.name}: {e}") + continue category_counts[category] = category_counts.get(category, 0) + 1 moved_count += 1 - # Visually print a few for effect, but not all to avoid spam - if moved_count <= 5 or moved_count % 50 == 0: + # Progress logging + if ( + moved_count <= PROGRESS_LOG_THRESHOLD + or moved_count % BATCH_PROGRESS_INTERVAL == 0 + ): print( f"{Colors.GREEN} โ†ณ Mapped '{folder.name}' โž” {category}/{Colors.ENDC}" ) - if moved_count > 5: + if moved_count > PROGRESS_LOG_THRESHOLD: + remaining = moved_count - PROGRESS_LOG_THRESHOLD + if moved_count % BATCH_PROGRESS_INTERVAL != 0: + print( + f"{Colors.GREEN} ...and {remaining} more skills safely migrated.{Colors.ENDC}" + ) + + if errors: + print(f"\n{Colors.WARNING}โš  Encountered {len(errors)} error(s):{Colors.ENDC}") + for error in errors[:5]: + print(f"{Colors.WARNING} - {error}{Colors.ENDC}") + if len(errors) > 5: + print( + f"{Colors.WARNING} ...and {len(errors) - 5} more errors.{Colors.ENDC}" + ) + + if dry_run: print( - f"{Colors.GREEN} ...and {moved_count - 5} more skills safely migrated.{Colors.ENDC}" + f"\n{Colors.BLUE}โœ” Would migrate {moved_count} skills to {hidden_library_dir}{Colors.ENDC}\n" + ) + else: + print( + f"\n{Colors.BLUE}โœ” Successfully migrated {moved_count} raw skills into the hidden vault at {hidden_library_dir}{Colors.ENDC}\n" ) - print( - f"\n{Colors.BLUE}โœ” Successfully migrated {moved_count} raw skills into the hidden vault at {hidden_library_dir}{Colors.ENDC}\n" - ) return category_counts -def generate_pointers(category_counts): - active_skills_dir = CONFIG["active_skills_dir"] - hidden_library_dir = CONFIG["hidden_library_dir"] +def generate_pointers(config: Config, category_counts: dict[str, int]) -> None: + """ + Create category pointer skills in the active directory. + Args: + config: The runtime configuration. + category_counts: Dictionary of category names to skill counts. + """ + active_skills_dir = config.active_skills_dir + hidden_library_dir = config.hidden_library_dir + dry_run = config.dry_run + + action = "Would generate" if dry_run else "Generating" print( - f"{Colors.BOLD}โšก Phase 2: Generating Dynamic Category Pointers...{Colors.ENDC}\n" + f"{Colors.BOLD}โšก Phase 2: {action} Dynamic Category Pointers...{Colors.ENDC}\n" ) pointer_template = """--- @@ -581,15 +807,27 @@ def generate_pointers(category_counts): created_pointers = 0 total_skills_indexed = 0 - # We will scan the hidden_library_dir completely to ensure we include skills added previously or manually - for cat_dir in hidden_library_dir.iterdir(): + try: + cat_dirs = list(hidden_library_dir.iterdir()) + except PermissionError: + print( + f"{Colors.FAIL}โœ– Error: Permission denied reading {hidden_library_dir}{Colors.ENDC}" + ) + return + + for cat_dir in cat_dirs: if not cat_dir.is_dir(): continue cat = cat_dir.name # Count actual SKILL.md files inside - count = sum(1 for p in cat_dir.rglob("SKILL.md")) + try: + count = sum(1 for _ in cat_dir.rglob("SKILL.md")) + except PermissionError: + print(f"{Colors.WARNING}โš  Skipping {cat} (permission denied){Colors.ENDC}") + continue + if count == 0: continue @@ -597,7 +835,21 @@ def generate_pointers(category_counts): pointer_name = f"{cat}-category-pointer" pointer_dir = active_skills_dir / pointer_name - pointer_dir.mkdir(parents=True, exist_ok=True) + + if dry_run: + print( + f"{Colors.CYAN} โŠ• Would create {pointer_name} โž” Indexes {count} skills.{Colors.ENDC}" + ) + created_pointers += 1 + continue + + try: + pointer_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + print( + f"{Colors.WARNING}โš  Could not create {pointer_dir} (permission denied){Colors.ENDC}" + ) + continue cat_title = cat.replace("-", " ").title() @@ -605,68 +857,175 @@ def generate_pointers(category_counts): category_name=cat, category_title=cat_title, count=count, - library_path=str(cat_dir.absolute()).replace( - "\\", "/" - ), # Ensure cross-platform path format in markdown + library_path=cat_dir.absolute().as_posix(), ) - with open(pointer_dir / "SKILL.md", "w", encoding="utf-8") as f: - f.write(content) + try: + with open(pointer_dir / "SKILL.md", "w", encoding="utf-8") as f: + f.write(content) + except (OSError, PermissionError) as e: + print( + f"{Colors.WARNING}โš  Could not write {pointer_dir / 'SKILL.md'}: {e}{Colors.ENDC}" + ) + continue created_pointers += 1 print( f"{Colors.CYAN} โŠ• Created {pointer_name} โž” Indexes {count} skills.{Colors.ENDC}" ) - print( - f"\n{Colors.BLUE}โœ” Successfully generated {created_pointers} ultra-lightweight pointers indexing {total_skills_indexed} total skills.{Colors.ENDC}" - ) + if dry_run: + print( + f"\n{Colors.BLUE}โœ” Would generate {created_pointers} pointers indexing {total_skills_indexed} total skills.{Colors.ENDC}" + ) + else: + print( + f"\n{Colors.BLUE}โœ” Successfully generated {created_pointers} ultra-lightweight pointers indexing {total_skills_indexed} total skills.{Colors.ENDC}" + ) + +def parse_args(argv: list[str] | None = None) -> tuple[Config, list[str]]: + """ + Parse command-line arguments and return configuration. -def main(): + Args: + argv: Command-line arguments (defaults to sys.argv). + + Returns: + Tuple of (Config, unknown_args). + """ import argparse - parser = argparse.ArgumentParser(description="SkillPointer Setup - Infinite Context. Zero Token Tax.") - parser.add_argument("--agent", choices=["opencode", "claude"], default="opencode", - help="Target AI agent (opencode or claude)") - args, unknown = parser.parse_known_args() + + parser = argparse.ArgumentParser( + description="SkillPointer Setup - Infinite Context. Zero Token Tax.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s --agent claude + %(prog)s --skill-dir ~/.agents/skills --vault-dir ~/.skillpointer-vault + %(prog)s --dry-run + """, + ) + parser.add_argument( + "--agent", + choices=["opencode", "claude"], + default="opencode", + help="Target AI agent (default: opencode)", + ) + parser.add_argument( + "--skill-dir", + type=str, + help="Directory to search for skills (overrides --agent default)", + ) + parser.add_argument( + "--vault-dir", + type=str, + help="Directory to move skills to when creating pointers (overrides --agent default)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without making them", + ) + parser.add_argument( + "--version", + action="version", + version=f"SkillPointer v{__version__}", + ) + + args, unknown = parser.parse_known_args(argv) + + config = Config() if args.agent == "claude": - CONFIG["agent_name"] = "Claude Code" - CONFIG["active_skills_dir"] = Path.home() / ".claude" / "skills" - CONFIG["hidden_library_dir"] = Path.home() / ".skillpointer-vault" + config.agent_name = "Claude Code" + config.active_skills_dir = Path.home() / ".claude" / "skills" + config.hidden_library_dir = Path.home() / ".skillpointer-vault" + + if args.skill_dir: + skill_dir = Path(args.skill_dir).expanduser().resolve() + if not skill_dir.exists(): + print( + f"{Colors.FAIL}โœ– Error: --skill-dir path does not exist: {skill_dir}{Colors.ENDC}" + ) + sys.exit(1) + if not skill_dir.is_dir(): + print( + f"{Colors.FAIL}โœ– Error: --skill-dir is not a directory: {skill_dir}{Colors.ENDC}" + ) + sys.exit(1) + config.active_skills_dir = skill_dir + + if args.vault_dir: + vault_dir = Path(args.vault_dir).expanduser().resolve() + config.hidden_library_dir = vault_dir + + if args.dry_run: + config.dry_run = True + + return config, unknown + + +def main(argv: list[str] | None = None) -> int: + """ + Main entry point for SkillPointer. + + Args: + argv: Command-line arguments (defaults to sys.argv). + + Returns: + Exit code (0 for success, non-zero for failure). + """ + config, unknown = parse_args(argv) # Handle 'install' argument for compatibility with Install.bat/vbs - if unknown and unknown[0] == "install": + if unknown and len(unknown) > 0 and unknown[0] == "install": pass + if config.dry_run: + print( + f"{Colors.WARNING}๐Ÿ” DRY RUN MODE - No changes will be made{Colors.ENDC}\n" + ) + print_banner() - if not setup_directories(): - return - time.sleep(1) - category_counts = migrate_skills() - time.sleep(1) - generate_pointers(category_counts) + if not setup_directories(config): + return 1 + + category_counts = migrate_skills(config) + generate_pointers(config, category_counts) print( f"\n{Colors.BOLD}{Colors.GREEN}=========================================={Colors.ENDC}" ) - print( - f"{Colors.BOLD}{Colors.GREEN}โœจ Setup Complete! Your AI is now optimized. โœจ{Colors.ENDC}" - ) - print( - f"{Colors.BOLD}{Colors.GREEN}=========================================={Colors.ENDC}" - ) - print(f"Your active skills directory now only contains optimized Pointers.") - print( - "When you prompt your AI, its context window will be completely empty, but it will dynamically fetch from your massive library exactly when needed." - ) + if config.dry_run: + print(f"{Colors.BOLD}{Colors.GREEN}โœจ Dry Run Complete! โœจ{Colors.ENDC}") + print( + f"{Colors.BOLD}{Colors.GREEN}=========================================={Colors.ENDC}" + ) + print("Run without --dry-run to apply these changes.") + else: + print( + f"{Colors.BOLD}{Colors.GREEN}โœจ Setup Complete! Your AI is now optimized. โœจ{Colors.ENDC}" + ) + print( + f"{Colors.BOLD}{Colors.GREEN}=========================================={Colors.ENDC}" + ) + print("Your active skills directory now only contains optimized Pointers.") + print( + "When you prompt your AI, its context window will be completely empty, " + "but it will dynamically fetch from your massive library exactly when needed." + ) + + return 0 if __name__ == "__main__": try: - main() + sys.exit(main()) except KeyboardInterrupt: print(f"\n{Colors.WARNING}Setup cancelled by user.{Colors.ENDC}") + sys.exit(130) except Exception as e: print(f"\n{Colors.FAIL}An unexpected error occurred: {e}{Colors.ENDC}") + sys.exit(1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d09f0b1 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for SkillPointer.""" diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..252130f --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,350 @@ +"""Tests for SkillPointer.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from setup import ( + Config, + get_category_for_skill, + main, + parse_args, + setup_directories, +) + + +class TestGetCategoryForSkill: + """Tests for get_category_for_skill function.""" + + def test_substring_match_python(self) -> None: + assert get_category_for_skill("python-pro") == "programming" + + def test_substring_match_security(self) -> None: + assert get_category_for_skill("security-scanner") == "security" + + def test_substring_match_debug(self) -> None: + assert get_category_for_skill("bug-hunter") == "debug" + + def test_substring_match_wordpress(self) -> None: + assert get_category_for_skill("generateblocks-expert") == "wordpress" + + def test_substring_match_documentation(self) -> None: + assert get_category_for_skill("code-documenter") == "documentation" + + def test_exact_match_quoted(self) -> None: + assert get_category_for_skill('"python"') == "programming" + + def test_exact_match_no_match(self) -> None: + assert get_category_for_skill('"xyz-nonexistent"') == "_uncategorized" + + def test_uncategorized_random_name(self) -> None: + assert get_category_for_skill("xyz-abc-123") == "_uncategorized" + + def test_pr_review_special_case(self) -> None: + assert get_category_for_skill("pr-review-agent") == "code-review" + + def test_pull_request_special_case(self) -> None: + # Note: "pull-request" also matches "git" category, so this returns "git" + # The special handling only applies when "review" is also in the name + result = get_category_for_skill("pull-request-helper") + assert result == "git" + + def test_merge_request_special_case(self) -> None: + # Note: "merge-request" also matches "git" category + result = get_category_for_skill("merge-request-bot") + assert result == "git" + + def test_review_without_pr_not_code_review(self) -> None: + result = get_category_for_skill("code-review-tool") + assert result == "code-review" + + def test_underscore_to_dash_conversion(self) -> None: + assert get_category_for_skill("python_pro") == "programming" + + def test_case_insensitive(self) -> None: + assert get_category_for_skill("PYTHON-PRO") == "programming" + + def test_empty_string(self) -> None: + assert get_category_for_skill("") == "_uncategorized" + + def test_web_dev_match(self) -> None: + assert get_category_for_skill("react-components") == "web-dev" + + def test_database_match(self) -> None: + assert get_category_for_skill("postgres-pro") == "database" + + def test_devops_match(self) -> None: + assert get_category_for_skill("docker-helper") == "devops" + + def test_ai_ml_match(self) -> None: + assert get_category_for_skill("llm-tools") == "ai-ml" + + +class TestConfig: + """Tests for Config dataclass.""" + + def test_default_config(self) -> None: + config = Config() + assert config.agent_name == "OpenCode" + assert ".config/opencode/skills" in str(config.active_skills_dir) + assert ".opencode-skill-libraries" in str(config.hidden_library_dir) + assert config.dry_run is False + + def test_custom_config(self) -> None: + config = Config( + agent_name="Claude Code", + active_skills_dir=Path("/custom/skills"), + hidden_library_dir=Path("/custom/vault"), + dry_run=True, + ) + assert config.agent_name == "Claude Code" + assert config.active_skills_dir == Path("/custom/skills") + assert config.hidden_library_dir == Path("/custom/vault") + assert config.dry_run is True + + +class TestSetupDirectories: + """Tests for setup_directories function.""" + + def test_nonexistent_skills_dir(self) -> None: + config = Config( + active_skills_dir=Path("/nonexistent/path/skills"), + ) + assert setup_directories(config) is False + + def test_creates_vault_dir(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + vault_dir = tmp_path / "vault" + + config = Config( + active_skills_dir=skills_dir, + hidden_library_dir=vault_dir, + ) + + assert setup_directories(config) is True + assert vault_dir.exists() + + def test_existing_vault_dir(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + vault_dir = tmp_path / "vault" + vault_dir.mkdir() + + config = Config( + active_skills_dir=skills_dir, + hidden_library_dir=vault_dir, + ) + + assert setup_directories(config) is True + + +class TestParseArgs: + """Tests for parse_args function.""" + + def test_default_args(self) -> None: + config, unknown = parse_args([]) + assert config.agent_name == "OpenCode" + assert config.dry_run is False + + def test_claude_agent(self) -> None: + config, unknown = parse_args(["--agent", "claude"]) + assert config.agent_name == "Claude Code" + assert ".claude/skills" in str(config.active_skills_dir) + + def test_custom_skill_dir(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + config, unknown = parse_args(["--skill-dir", str(skills_dir)]) + assert config.active_skills_dir == skills_dir.resolve() + + def test_custom_vault_dir(self, tmp_path: Path) -> None: + vault_dir = tmp_path / "vault" + + config, unknown = parse_args(["--vault-dir", str(vault_dir)]) + assert config.hidden_library_dir == vault_dir.resolve() + + def test_dry_run_flag(self) -> None: + config, unknown = parse_args(["--dry-run"]) + assert config.dry_run is True + + def test_skill_dir_expands_home(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + skills_dir = Path(tmpdir) / "skills" + skills_dir.mkdir() + + with patch.dict("os.environ", {"HOME": tmpdir}): + config, unknown = parse_args(["--skill-dir", "~/skills"]) + assert "skills" in str(config.active_skills_dir) + + def test_nonexistent_skill_dir_exits(self, capsys: pytest.CaptureFixture) -> None: + with pytest.raises(SystemExit) as exc_info: + parse_args(["--skill-dir", "/nonexistent/path"]) + assert exc_info.value.code == 1 + + def test_install_arg_compatibility(self) -> None: + config, unknown = parse_args(["install"]) + assert "install" in unknown + + def test_combined_args(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + vault_dir = tmp_path / "vault" + + config, unknown = parse_args( + [ + "--agent", + "claude", + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + "--dry-run", + ] + ) + + assert config.agent_name == "Claude Code" + assert config.active_skills_dir == skills_dir.resolve() + assert config.hidden_library_dir == vault_dir.resolve() + assert config.dry_run is True + + +class TestMain: + """Tests for main function.""" + + def test_dry_run_no_changes(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "test-skill").mkdir() + (skills_dir / "test-skill" / "SKILL.md").write_text("---\nname: test\n---\n") + + vault_dir = tmp_path / "vault" + + result = main( + [ + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + "--dry-run", + ] + ) + + assert result == 0 + # Skills should NOT be moved in dry-run mode + assert (skills_dir / "test-skill").exists() + # Vault is created by setup_directories, but skills shouldn't be inside + assert not (vault_dir / "testing" / "test-skill").exists() + + def test_full_migration(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "python-pro").mkdir() + (skills_dir / "python-pro" / "SKILL.md").write_text("---\nname: python-pro\n---\n") + + vault_dir = tmp_path / "vault" + + result = main( + [ + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + ] + ) + + assert result == 0 + assert not (skills_dir / "python-pro").exists() + assert (vault_dir / "programming" / "python-pro").exists() + assert (skills_dir / "programming-category-pointer").exists() + + def test_handles_existing_pointers(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + pointer_dir = skills_dir / "test-category-pointer" + pointer_dir.mkdir() + (pointer_dir / "SKILL.md").write_text("---\nname: test-pointer\n---\n") + + vault_dir = tmp_path / "vault" + + result = main( + [ + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + ] + ) + + assert result == 0 + assert pointer_dir.exists() + + def test_handles_empty_folders(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "empty-folder").mkdir() + + vault_dir = tmp_path / "vault" + + result = main( + [ + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + ] + ) + + assert result == 0 + assert (skills_dir / "empty-folder").exists() + + +class TestIntegration: + """Integration tests for complete workflows.""" + + def test_full_workflow_with_multiple_skills(self, tmp_path: Path) -> None: + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + + # Note: Use names that won't accidentally match keywords + # (e.g., "random-tool" contains "dom" which matches web-dev's "dom") + skills = [ + ("python-pro", "programming"), + ("postgres-pro", "database"), + ("react-components", "web-dev"), + ("security-scanner", "security"), + ("xyz-abc-123", "_uncategorized"), + ] + + for skill_name, _ in skills: + skill_dir = skills_dir / skill_name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text(f"---\nname: {skill_name}\n---\n") + + vault_dir = tmp_path / "vault" + + result = main( + [ + "--skill-dir", + str(skills_dir), + "--vault-dir", + str(vault_dir), + ] + ) + + assert result == 0 + + for skill_name, expected_category in skills: + assert not (skills_dir / skill_name).exists(), f"{skill_name} not moved" + assert (vault_dir / expected_category / skill_name).exists(), ( + f"{skill_name} not in {expected_category}" + ) + + for expected_category in set(cat for _, cat in skills): + pointer_dir = skills_dir / f"{expected_category}-category-pointer" + assert pointer_dir.exists(), f"Pointer for {expected_category} not created"