From ff79372600662649070b6e7fb1717d7775ad6f4d Mon Sep 17 00:00:00 2001 From: JC Yamokoski Date: Wed, 3 Jun 2026 23:56:54 -0400 Subject: [PATCH 1/3] feat: make skills and vault paths user-configurable Add --skills-dir and --vault-dir flags so paths are no longer locked to the two hardcoded --agent presets. --agent now provides base defaults that explicit path flags override (additive, backward-compatible). Harden the destructive migration (shutil.move/rmtree) per CLI guidelines: - --dry-run previews the plan without moving or writing anything - confirmation prompt before migrating, with --force/--yes to skip and a non-interactive guard that refuses without --force - path validation: reject identical skills/vault paths or a vault nested inside the skills dir - respect --no-color, NO_COLOR, TERM=dumb, and non-TTY for color output - proper exit codes (0 success, 1 failure, 130 on Ctrl-C) Legacy 'python setup.py install' (Install.bat/vbs) still works via parse_known_args. Document the new flags and a custom-path example in the README. --- README.md | 20 +++++ setup.py | 255 ++++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 247 insertions(+), 28 deletions(-) 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..1bd698c 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}Please ensure {agent_name} is installed and configured.{Colors.ENDC}" + 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.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,6 +554,53 @@ 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 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) @@ -526,8 +609,6 @@ def migrate_skills(): shutil.rmtree(dest) shutil.move(str(folder), str(cat_dir)) - - category_counts[category] = category_counts.get(category, 0) + 1 moved_count += 1 # Visually print a few for effect, but not all to avoid spam @@ -544,7 +625,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 +703,144 @@ 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: + if plan: + categories = ", ".join(sorted(category_counts)) + print( + f"{Colors.CYAN}Pointers that would be generated: {categories}{Colors.ENDC}" + ) + print(f"\n{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 +857,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) From 23464ca155ab35ac065a6ada2aa28b56143fc10c Mon Sep 17 00:00:00 2001 From: JC Yamokoski Date: Thu, 4 Jun 2026 00:08:28 -0400 Subject: [PATCH 2/3] feat: show per-skill categorization detail in --dry-run Dry-run previously reported only per-category counts. The skill -> category mapping was already computed in build_migration_plan() but never printed, so skills falling into _uncategorized vanished into a number. Add print_migration_detail(), called from the dry-run branch, which groups the plan by category and lists each skill folder under its destination. The _uncategorized bucket is forced last and highlighted so unmatched skills are easy to spot and fix before the destructive move. The interactive confirm path keeps the compact count summary to avoid spamming the prompt at scale. --- setup.py | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/setup.py b/setup.py index 1bd698c..595d9dd 100644 --- a/setup.py +++ b/setup.py @@ -584,6 +584,42 @@ def print_migration_summary(plan, category_counts): 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: @@ -809,12 +845,8 @@ def main(): print_migration_summary(plan, category_counts) if args.dry_run: - if plan: - categories = ", ".join(sorted(category_counts)) - print( - f"{Colors.CYAN}Pointers that would be generated: {categories}{Colors.ENDC}" - ) - print(f"\n{Colors.BOLD}Dry run - no changes made.{Colors.ENDC}") + print_migration_detail(plan) + print(f"{Colors.BOLD}Dry run - no changes made.{Colors.ENDC}") return 0 if not plan: From 70c9d039c6a3c8163802e4694481b3834bceeda3 Mon Sep 17 00:00:00 2001 From: JC Yamokoski Date: Thu, 4 Jun 2026 10:22:43 -0400 Subject: [PATCH 3/3] fix: safe symlink handling and explicit move dest in execute_migration Ported from PR #7 by @redlotusaustin. shutil.rmtree on a symlink follows it into the target and destroys its contents; use unlink() instead. Also pass the explicit dest path to shutil.move rather than the parent category dir to remove ambiguity. --- setup.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 595d9dd..a41756a 100644 --- a/setup.py +++ b/setup.py @@ -642,9 +642,12 @@ def execute_migration(plan): dest = cat_dir / folder.name if dest.exists(): - shutil.rmtree(dest) + if dest.is_symlink() or dest.is_file(): + dest.unlink() + else: + shutil.rmtree(dest) - shutil.move(str(folder), str(cat_dir)) + shutil.move(str(folder), str(dest)) moved_count += 1 # Visually print a few for effect, but not all to avoid spam