diff --git a/README.md b/README.md index 0be329b..b732169 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,26 @@ python setup.py --agent claude ``` *(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).* +**Custom paths:** +The `--agent` presets are just defaults. Point the script at any skills directory and vault you like — for example, if you keep skills in a universal, agent-agnostic location: +```bash +python setup.py --skills-dir ~/.agents/skills --vault-dir ~/.my-vault +``` +Explicit `--skills-dir` / `--vault-dir` override the `--agent` preset defaults. + +**Options:** + +| Flag | Description | +|---|---| +| `--agent {opencode,claude}` | Base preset providing default paths (default: `opencode`). | +| `--skills-dir PATH` | Active skills directory to reorganize (overrides the preset). | +| `--vault-dir PATH` | Hidden vault directory to move raw skills into (overrides the preset). | +| `-n`, `--dry-run` | Show what would happen without moving or writing anything. | +| `-f`, `--force` / `-y`, `--yes` | Skip the confirmation prompt before migrating. | +| `--no-color` | Disable colored output. | + +> The migration **moves** your skill folders. Run with `--dry-run` first to preview the plan; the script confirms before making any changes unless you pass `--force`. + ### Step 2: Test It! Start your AI agent and ask it to fetch a specific skill: > *"I want to create a CSS button. Please consult your `web-dev-category-pointer` first to find the exact best practice from your library before writing the code."* diff --git a/setup.py b/setup.py index 7441f8e..a41756a 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,14 @@ class Colors: ENDC = "\033[0m" BOLD = "\033[1m" + _CODES = ("HEADER", "BLUE", "CYAN", "GREEN", "WARNING", "FAIL", "ENDC", "BOLD") + + @classmethod + def disable(cls): + """Blank out every color code so all output is plain text.""" + for name in cls._CODES: + setattr(cls, name, "") + # Global configuration state CONFIG = { @@ -476,41 +484,69 @@ def get_category_for_skill(skill_name: str) -> str: return "_uncategorized" -def setup_directories(): - agent_name = CONFIG["agent_name"] +def validate_directories(): + """Validate the configured paths before any destructive action. + + Returns True if the paths are usable. Does NOT create anything, so it is + safe to call under --dry-run. + """ active_skills_dir = CONFIG["active_skills_dir"] hidden_library_dir = CONFIG["hidden_library_dir"] if not active_skills_dir.exists(): print( - f"{Colors.FAIL}✖ Error: {agent_name} skills directory not found at {active_skills_dir}{Colors.ENDC}" + f"{Colors.FAIL}✖ Error: skills directory not found at {active_skills_dir}{Colors.ENDC}" + ) + print( + f"{Colors.WARNING}Create it, or pass --skills-dir pointing at an existing skills directory.{Colors.ENDC}" + ) + return False + + if not active_skills_dir.is_dir(): + print( + f"{Colors.FAIL}✖ Error: {active_skills_dir} is not a directory.{Colors.ENDC}" ) + return False + + # Resolve to absolute paths for the containment checks below. + skills_resolved = active_skills_dir.resolve() + vault_resolved = hidden_library_dir.resolve() + + if skills_resolved == vault_resolved: print( - f"{Colors.WARNING}Please ensure {agent_name} is installed and configured.{Colors.ENDC}" + f"{Colors.FAIL}✖ Error: skills dir and vault dir must be different paths.{Colors.ENDC}" + ) + return False + + # Migration iterates the skills dir; if the vault lived inside it we would + # try to move the vault into itself. (.parents is 3.8-safe.) + if skills_resolved in vault_resolved.parents: + print( + f"{Colors.FAIL}✖ Error: vault dir ({vault_resolved}) cannot be nested " + f"inside skills dir ({skills_resolved}).{Colors.ENDC}" ) return False - hidden_library_dir.mkdir(parents=True, exist_ok=True) return True -def migrate_skills(): - active_skills_dir = CONFIG["active_skills_dir"] - hidden_library_dir = CONFIG["hidden_library_dir"] +def build_migration_plan(): + """Read-only scan of the active skills dir. - print(f"{Colors.BOLD}📦 Phase 1: Analyzing and Migrating Skills...{Colors.ENDC}\n") + Returns (plan, category_counts) where plan is a list of (folder, category). + Makes no filesystem changes, so it is safe under --dry-run. + """ + active_skills_dir = CONFIG["active_skills_dir"] + plan = [] category_counts = {} - moved_count = 0 - pointer_count = 0 - for folder in list(active_skills_dir.iterdir()): + for folder in sorted(active_skills_dir.iterdir()): if not folder.is_dir(): continue # Ignore existing pointers if folder.name.endswith("-category-pointer"): - pointer_count += 1 continue # Ignore empty folders @@ -518,16 +554,100 @@ def migrate_skills(): continue category = get_category_for_skill(folder.name) + plan.append((folder, category)) + category_counts[category] = category_counts.get(category, 0) + 1 + + return plan, category_counts + + +def print_migration_summary(plan, category_counts): + """Print what the migration will do. Used for both confirmation and dry-run.""" + active_skills_dir = CONFIG["active_skills_dir"] + hidden_library_dir = CONFIG["hidden_library_dir"] + + print(f"{Colors.BOLD}📋 Migration plan{Colors.ENDC}\n") + print(f" Source : {active_skills_dir}") + print(f" Vault : {hidden_library_dir}\n") + + if not plan: + print( + f"{Colors.WARNING} No skills to migrate " + f"(nothing matched, or already organized).{Colors.ENDC}\n" + ) + return + + print( + f" {len(plan)} skill(s) ➔ {len(category_counts)} categor(y/ies):" + ) + for category in sorted(category_counts): + print(f" {Colors.CYAN}• {category}{Colors.ENDC}: {category_counts[category]}") + print() + + +def print_migration_detail(plan): + """Print the full skill -> category mapping, grouped by category. + + Read-only; used by --dry-run so the user can verify categorization before the + destructive move. `_uncategorized` is forced last and highlighted. + """ + if not plan: + return + + hidden_library_dir = CONFIG["hidden_library_dir"] + + # Group skill names by category without mutating the passed-in plan. + by_category = {} + for folder, category in plan: + by_category.setdefault(category, []).append(folder.name) + + # Alphabetical, but always show the catch-all bucket last. + categories = sorted(by_category, key=lambda c: (c == "_uncategorized", c)) + + print(f"{Colors.BOLD}🔎 Detailed mapping{Colors.ENDC}\n") + for category in categories: + skills = sorted(by_category[category]) + dest = hidden_library_dir / category + if category == "_uncategorized": + print( + f" {Colors.WARNING}{category} ({len(skills)}){Colors.ENDC} " + f"➔ {dest}/ " + f"{Colors.WARNING}(matched no keyword — review these){Colors.ENDC}" + ) + else: + print(f" {Colors.CYAN}{category} ({len(skills)}){Colors.ENDC} ➔ {dest}/") + for name in skills: + print(f" - {name}") + print() + + +def confirm(prompt): + """Prompt for a y/N confirmation. Returns True only on an affirmative answer.""" + try: + answer = input(f"{Colors.BOLD}{prompt} [y/N]: {Colors.ENDC}").strip().lower() + except EOFError: + return False + return answer in ("y", "yes") + + +def execute_migration(plan): + """Move each planned skill folder into its category dir in the vault.""" + hidden_library_dir = CONFIG["hidden_library_dir"] + + print(f"{Colors.BOLD}📦 Phase 1: Migrating Skills...{Colors.ENDC}\n") + + moved_count = 0 + for folder, category in plan: cat_dir = hidden_library_dir / category cat_dir.mkdir(parents=True, exist_ok=True) dest = cat_dir / folder.name if dest.exists(): - shutil.rmtree(dest) - - shutil.move(str(folder), str(cat_dir)) + if dest.is_symlink() or dest.is_file(): + dest.unlink() + else: + shutil.rmtree(dest) - category_counts[category] = category_counts.get(category, 0) + 1 + shutil.move(str(folder), str(dest)) moved_count += 1 # Visually print a few for effect, but not all to avoid spam @@ -544,7 +664,6 @@ def migrate_skills(): 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): @@ -623,28 +742,140 @@ def generate_pointers(category_counts): ) -def main(): +def build_parser(): 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( + prog="setup.py", + description="SkillPointer Setup - Infinite Context. Zero Token Tax.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +examples: + # OpenCode defaults (~/.config/opencode/skills -> ~/.opencode-skill-libraries) + python setup.py + + # Claude Code preset (~/.claude/skills -> ~/.skillpointer-vault) + python setup.py --agent claude + + # Custom paths: skills kept in a universal, agent-agnostic location + python setup.py --skills-dir ~/.agents/skills --vault-dir ~/.my-vault + + # Preview the plan without moving or writing anything + python setup.py --skills-dir ~/.agents/skills --vault-dir ~/.my-vault --dry-run + +Explicit --skills-dir / --vault-dir override the --agent preset defaults. +""", + ) + parser.add_argument( + "--agent", + choices=["opencode", "claude"], + default="opencode", + help="Base preset providing default paths (default: opencode).", + ) + parser.add_argument( + "--skills-dir", + metavar="PATH", + help="Active skills directory to reorganize (overrides the --agent preset).", + ) + parser.add_argument( + "--vault-dir", + metavar="PATH", + help="Hidden vault directory to move raw skills into (overrides the --agent preset).", + ) + parser.add_argument( + "-n", + "--dry-run", + action="store_true", + help="Show what would happen without moving or writing anything.", + ) + parser.add_argument( + "-f", + "--force", + "-y", + "--yes", + dest="force", + action="store_true", + help="Skip the confirmation prompt before migrating.", + ) + parser.add_argument( + "--no-color", + action="store_true", + help="Disable colored output.", + ) + return parser + + +def apply_config(args): + """Resolve final paths: --agent preset first, then explicit flag overrides.""" 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" - # Handle 'install' argument for compatibility with Install.bat/vbs - if unknown and unknown[0] == "install": - pass + if args.skills_dir: + CONFIG["active_skills_dir"] = Path(args.skills_dir).expanduser() + if args.vault_dir: + CONFIG["hidden_library_dir"] = Path(args.vault_dir).expanduser() + + +def should_use_color(no_color_flag): + """Color is on only for an interactive terminal that hasn't opted out.""" + return ( + sys.stdout.isatty() + and os.environ.get("NO_COLOR", "") == "" + and os.environ.get("TERM") != "dumb" + and not no_color_flag + ) + + +def main(): + # parse_known_args keeps the legacy `python setup.py install` invocation + # (Install.bat / Install.vbs) working by ignoring the extra positional. + parser = build_parser() + args, _unknown = parser.parse_known_args() + + if not should_use_color(args.no_color): + Colors.disable() + + apply_config(args) print_banner() - if not setup_directories(): - return + + if not validate_directories(): + return 1 + + plan, category_counts = build_migration_plan() + print_migration_summary(plan, category_counts) + + if args.dry_run: + print_migration_detail(plan) + print(f"{Colors.BOLD}Dry run - no changes made.{Colors.ENDC}") + return 0 + + if not plan: + print(f"{Colors.WARNING}Nothing to do.{Colors.ENDC}") + return 0 + + if not args.force: + if not sys.stdin.isatty(): + print( + f"{Colors.FAIL}✖ Refusing to migrate without confirmation in a " + f"non-interactive session.{Colors.ENDC}" + ) + print( + f"{Colors.WARNING}Re-run with --force (or --dry-run to preview).{Colors.ENDC}" + ) + return 1 + if not confirm("This will MOVE the skills above into the vault. Proceed?"): + print(f"{Colors.WARNING}Cancelled - no changes made.{Colors.ENDC}") + return 0 + + # Ensure the vault root exists even if nothing lands at its top level, + # so generate_pointers() can scan it safely. + CONFIG["hidden_library_dir"].mkdir(parents=True, exist_ok=True) time.sleep(1) - category_counts = migrate_skills() + execute_migration(plan) time.sleep(1) generate_pointers(category_counts) @@ -661,12 +892,15 @@ def main(): 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)