From 2333c0013202483d6e0299a1aea7273f95203a75 Mon Sep 17 00:00:00 2001 From: ElCorbacho Date: Mon, 9 Mar 2026 01:20:23 -0300 Subject: [PATCH 1/4] feat(setup): add debug category with comprehensive debugging terms Add new 'debug' domain category to DOMAIN_HEURISTICS with keywords covering: - Debugging tools and techniques (debug, breakpoint, trace) - Logging frameworks (logger, logging) - Performance profiling (profiler, profiling, devtools) - Monitoring and observability (sentry, datadog, newrelic) - Error tracking and diagnostics (error-tracking, diagnostic) This enables automatic skill classification for debugging-related tools and extensions. --- setup.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/setup.py b/setup.py index 33283c8..38d6ad8 100644 --- a/setup.py +++ b/setup.py @@ -333,6 +333,26 @@ class Colors: "data-", "etl", ], + "debug": [ + "debug", + "debugging", + "breakpoint", + "logger", + "logging", + "trace", + "profiler", + "profiling", + "devtools", + "inspector", + "monitor", + "troubleshoot", + "diagnostic", + "error-tracking", + "sentry", + "datadog", + "newrelic", + "bugtracking", + ], "education": [ "learning", "course", From 2503ac6c6bf735ef082427cbf0a18b68ae6181ff Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Fri, 13 Mar 2026 20:31:43 -0500 Subject: [PATCH 2/4] feat: add --skill-dir and --vault-dir arguments for custom directories - Add --skill-dir to override source skills directory - Add --vault-dir to override destination vault directory - Fix symlink handling to prevent source deletion - Fix shutil.move to use explicit destination path --- .megamemory/knowledge.db | Bin 0 -> 4096 bytes .megamemory/knowledge.db-shm | Bin 0 -> 32768 bytes .megamemory/knowledge.db-wal | Bin 0 -> 152472 bytes setup.py | 39 +++++++++++++++++++++++++++++------ 4 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 .megamemory/knowledge.db create mode 100644 .megamemory/knowledge.db-shm create mode 100644 .megamemory/knowledge.db-wal diff --git a/.megamemory/knowledge.db b/.megamemory/knowledge.db new file mode 100644 index 0000000000000000000000000000000000000000..0a06b00940a2e489182e153184a104fe6003c831 GIT binary patch literal 4096 zcmWFz^vNtqRY=P(%1ta$FlG>7U}9o$P*7lCU|@t|AVoG{WY8 z?9JlSw|5^uoX`LA{`OVB^Qyi6dXWC{eYM|?N9`=Nlh=>iS?z4x^DtoKImK6`BS_c?$52O&U!009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5ct;ugSKa47^4`+ zB&M;^HbCseUObJ1coxTT5-%h7VF-c$Q6TrN2!WacxoJoU)D+0wT0)?vKyH*10yPD4 zf1(hmDUjPjg+NV#+>I*)Y6|2gXdzHjAordNftmujb*Kx-LhQ zW;RZM2wGYQ=^qt;aI2z~TH11|gi2LGtq`;-g>tkIP^nZ=5Ld+|B<}0IH~VJZ?Cg4- zrP$5tJ5rpTcg)Uwc6Mgoe!uVcyW%~`o7#3i|BAM@O>Jzs;TP^j#ov8>-`}tM_0rFi zUwj`eN<_-<|IxeuadH2nQ*X)G9#Q8@PQmolOKI=+_O`24m9}lC|2D8bm4)^15G(Y5 zzCCHZ*30&`*Btv`|KMNS+WRkV>%VyL$y$w21_B@e0w4eaAOHd&00JNY0xul`x9muC zDT;3Qi=E&pU%yw*XlF7 zr0SwsAbx}FWM0iqXEJi_5xZFFUaQtE7Ye3xqJH<~!+xc_$$5K`7!H|tc{jOc$#h83 zGb%eQ*UUM@^oV85o-jPqJre6F>go8AAM2<-mY&Y!)s#iMd3J%MhKi--Ax)DvT3%wc zmT^v<98xG93p>f%6IFCy-BK~OkEAe5EXyt~sKc4bVYy?0I19vBa7yK+Sg#@>mTT}9 z>O?lLkLx*A9Nk`dBW|he%n@VOF^hADW1I2cf{}1Nvk-rqvB{i1F`i{7hnfmL2TjfC zV|q@{j_6bVnU%6FO`XirUC8M4oSV}9Qzkf{G_5_UT&X15=y^kqyGIx4$lfr^UWu<8 z{OmOL@&DMkzPs}MV&nYa_ks_f+ILm)BhJ13tkC}{fxW^40w4eaAOHd&00JNY0w4ea zAOHd&(3%9;Zv-2Jk6`zW-Jf{q&p)5Ar1x?Q*=;*zV&m-vl2YrDB1V8`;KmY_l00ck) z1R4=I(2-Pj?o7;ZBX`9k>yo2oQk)}ItDO;ZcU5JIdAE1bp4!c+4cr`lY7gHgu@;ES z|8zw9^P*~VxNsFq{%|!tou8b@(!TrkY+iO5&pV}pFlei_Ea6^h#cGEvnx0*8N7vi{ zwYASV_EOMDe)pzq-Y+$Gbp>HVZ}mueY9u|X%f}yaX>-!6-AZWC#ykSdBWT%q1bj>{ z`~nX@_{5VP*B8Ezc?6AaNT>$^5C8!X009sH0T2KI5C8!X0D+buz_kBP@w>pQFS_8? z!F%5NPy8;>5}z#&0t7$+1V8`;KmY_l00ck)1V8`;8WD)hBUpI3`;#|bc=lrr<`E>N zjk5RzP0u6f?41tg5zv=dKmY_l00ck)1V8`;T8TieGpX!W5`G+P+d5teh3$vh=JBwT z6|4S4c2qy8RyScmw|O%uIydGK_)*p|k6@)3?U+Xpq(V@Uk04}$d<5bm9M4C<#`sC$ z7kJz0PyVd!vd0Dxcd(Ui$T$`d009sH0T2KI5C8!X009sH0T8H5AmSIe^rAcOIQj8k z<{J0~x}>c!uRzm&fn;y5;-KmY_l00ck)1lF0rL^7#pO2XvHu0sW%B~jUO!0l*iZVo?BxdV z3&1Zx472R{zO9OBEAAIyWBe}R7x>}rU)b?hM}HiCfpxwe&M++J1K(ZNJhcoWEZwFicN_UjTUu1A!HJ3Td`O^pxKY@dfI~7hq%jZs8aB_GK4d{=NhE-vz(G>Tbqp1p*)d0w4ea zAOHd&00JNY0w4ea=Q)9hU%>v`^RK({yFYoNfnT6UI%XEPuxY-W^r^rd#8t}hj8@Cy+4=%Vcr!zD+{q&Qc- z+F2o+PY-8wwW@8ZXcovW+EcqZwV_K4f!25!!BKz!2!H?xfB*=900@8p2!H?xfItHR5x>BO zH~-eS^W?LyYv31<^9HcEg-!bfdbRU^9|7_asHzAePzg#PEB2xa@deJ=FTlq5 zy}~cB?`!WKdg@i*Lwtd?coM;TfB*=900@8p2!H?xfB*=900@A<`V)xw1)hK4sTbz1 zI&*Uazd)a~+bnKj(|&>8?z?OF1!y4_5C8!X009sH0T8G~Ak&*vG$pYR%-H91!3`!N z@>hK^X+LbhG=0975}G6Y0-?`A$ZW_*fcOHk{3^w>h=qQayfJGw>soww8_l_{Ly0P+#EW_$rQ#_tn;fm@%~pHAKT!aEROpw@#3 z%0K`FKmY_l00ck)1V8`;KmY_lpydce`~n|+`dtqU{KDMSz%Q^-qQK%7HtiSa>%O6; zUw{^40Ra#I0T2KI5NIU=V|_{GA|+u|>HJ=a3-}xBsQg|j)cAdU4}JkjzePTR#p(nE zRVfFMkD!*^drq0+rSlDb0ad$YM}nuWI6cHuSMc~F{7-u%^#w0dQ$c`uRkbaSPd$*E z*q_eLFw=tk*|6LgeuM0!A6#CpJwmUW^=sX7pNi+E>{rU0oVOQ=;gETkcav*^ zIQX#|_ywE=vS!Yh;B#2Bd<1Nazft%Fp8o0uKYaN0&z*%|pp_oPa4aAI0w4eaAOHd& z00JNY0w4eaAW)Y;#4m8cPrv;3FTL@aOB(nE`lYS0pAee%3vBF8*6|C_3M?Q10w4ea zAOHgEKwx@fQrV{@j#df#4p}rkyHw=zet#W#Uo;9ueo>;qFL2zpjvHJ?^wVG)3r?xL z#0z?2JY(&`H~^A<3%>yT0@>+IMizT5;?6nt68o;!m3!eAKzsoeeu3f4kq3(Wre@z3A+d+VA8et`jLH(A`mru_o_U7xP&7ob&GKmY_l00ck)1Y!gZ^(U1p zl*Gv@Jzvr~xTHT2lc0IAELDf9zSLQRU%)jaafl1|y)l@+o-efv^?a#NJJenEVa0p^ zya0ZIyiRBBj_L>LENo$_@YS-AYp9+b=twF%cP3`|1n;VV0#&PsFCYt|VtP3I0`LpK zFQCFNP@?Xl4fGL%qC^oj8-O9h~&{QM?rP9M{AdUiygs+_jA zrLhY|bSpt#0pug_h-J*4Fg(*e5}SqJlp&?OWSMNE=j38>NTGBr>?D86qRJtrTO-H@ zDa;bfvWpAw3-If&>=$5T{6XOtc*Bq1((#+8{<0Zu8Pt~wq=(b4f6C#8JE@cbbfLoOZ)HF zvw8VfdbSnUGYd<;lr^f4rKdA_HN~_5R+kzomX?P!Ez+k{TCA^pQNhqG_}P_fDq{|D z%Zr}TFnP1>8q1FDg?Wc-$!xR{ah#G99CWQl=KMUFW739gRqa=(C&4d3H5#h7arHhW z^iAX=Ajn4`5rALdAWc$V!w|q#?E2S4@qMb_0)Bz0(l6g{;1>w)Q!3&Mz%LN<3$QW% zCgB&zJaF~xXO8~yn>-PrJo<0$nH0T100ck)1V8`;KmY_l00ck)1V8`;UfKjAeu45m z56(S%`U78S;1}2|9TJOM*tB0@Q>WFyFF+e$0Ra#Iffge`Hgzel-=4TJ5zMz0YP}H% z?xJLKY~rn`3EMJdR+OfKEokryNWuvG0zAsQAHP6KJrJt;QX%pY(24X?JO?hRAie;-Kwy0cOm9vqd$uPEHFSMCDPBnQTUL&U6=yj#Q$4GgFR(sC5WFi0fB*=900@8p z2!H?xfB*=900^uxfrww=g7=>tyZ4E?nFfA=tfB*=@ z2$)-F+VSm)lYyQk^m{|hB^L!-V``bG37SSkebHZoUjX?CV!90S5fr(rp+EMm^34uG zv~=Vn;M$XRsGDA>6zM|cYxR;a#S403JY(%bU0>=9zrZ4?%$=qAQ_7?`SH0Rq&X8xxm(w`T>FA&%T)b&tN;TMSAm*5wWWq)CO za){~D+&FptU?g!8Da;bfvWpAIN5Jng8|dS8cjXbAjq$e%zrane{LFP{FWlA#zd&pN zf&vf#0T2KI5C8!X009sH0T2KI5NJgL5x>CrkprK4$FCne(ZDaTO#;E<7B=k{*xE6^ zD!%}2h6Myb;QS(BZe2ml=Q-kqWWOyYlZhH8=KJa7?6d2$C|&zI%WV4l8I2)}@q>r5)Um4qJ$+qRBZ zLSg$;XF0`W>@MKL^_zq~iEy^`HC2q$lPTty86s;4Z| zBkb;x8Y-5Shcr#@B>7Wf+N*S~2NzDn7htI?_&Ho{En(8ND&?(^F*EOq|EbivJ+aUp z^=si5kV(QXpsG#gBVc3vZNe|`se$3r;B$o$_yx|d0T}ic1V8`;KmY_l00ck)1V8`; zKmY`qB*6Rv!S4d;Gk?1Jj!)b^@jvOkPUCIG*{ zqFp2gkF_6B^O29BT8ctG0;;l&>b?Z+PtlhZIC}Q*!jx2qd<4ix;2<9X`~pmRuB9wn z#GP~OCC{!gwHkhboIWw0Rj<`&)Rb2;Y^z~r_>uI~NP1Kc6T{!KBf&#aoF3wK748e* zf7&CVDBu@hC#dj_tY}CnFEP7{Wt@|XMVJCAo!}SXK_|k~RBKNvS1O4%9)hc4qB6^# Q@7t=Fw)XLpG>9+oUoIT;+yDRo literal 0 HcmV?d00001 diff --git a/setup.py b/setup.py index 7441f8e..12ba739 100644 --- a/setup.py +++ b/setup.py @@ -454,7 +454,9 @@ def get_category_for_skill(skill_name: str) -> str: 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("_", "-") @@ -523,9 +525,12 @@ def migrate_skills(): 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)) category_counts[category] = category_counts.get(category, 0) + 1 moved_count += 1 @@ -625,9 +630,26 @@ def generate_pointers(category_counts): def main(): 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)") + + 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)", + ) + 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)", + ) args, unknown = parser.parse_known_args() if args.agent == "claude": @@ -635,6 +657,11 @@ def main(): CONFIG["active_skills_dir"] = Path.home() / ".claude" / "skills" CONFIG["hidden_library_dir"] = Path.home() / ".skillpointer-vault" + if args.skill_dir: + CONFIG["active_skills_dir"] = Path(args.skill_dir).expanduser().resolve() + if args.vault_dir: + CONFIG["hidden_library_dir"] = Path(args.vault_dir).expanduser().resolve() + # Handle 'install' argument for compatibility with Install.bat/vbs if unknown and unknown[0] == "install": pass From 0b24587747a91c4efb28c1bdfa40181c5251eb52 Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Fri, 13 Mar 2026 21:07:12 -0500 Subject: [PATCH 3/4] feat: add documentation and wordpress categories, expand debug/tooling/programming/security - Add 'documentation' category for code-documenter, docstrings, swagger, etc - Add 'wordpress' category for wordpress, generateblocks, gutenberg, woocommerce - Add bug-hunter/hunter/bug keywords to debug category - Add vibesec/vibe-security keywords to security category - Add find-skills to tooling category - Add mago to programming category --- setup.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/setup.py b/setup.py index e5bc60a..2964a1a 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,8 @@ class Colors: "security", "exploit", "encryption", + "vibesec", + "vibe-security", ], "code-review": [ "code-review", @@ -355,6 +357,9 @@ class Colors: "datadog", "newrelic", "bugtracking", + "bug", + "bug-hunter", + "hunter", ], "education": [ "learning", @@ -411,6 +416,7 @@ class Colors: "kotlin", "algorithm", "data-structure", + "mago", ], "prompt-engineering": [ "system-prompt", @@ -460,6 +466,30 @@ 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", ], } From c11a9203b977abad565582e36f283c2b6bccebcb Mon Sep 17 00:00:00 2001 From: Ruben Ramirez Date: Fri, 13 Mar 2026 21:21:23 -0500 Subject: [PATCH 4/4] refactor: address all code review issues High Priority: - Add path validation for --skill-dir and --vault-dir arguments - Add comprehensive error handling for directory operations - Fix symlink handling to prevent source deletion Medium Priority: - Add type hints to all functions - Define magic numbers as constants (PROGRESS_LOG_THRESHOLD, BATCH_PROGRESS_INTERVAL) - Fix IndexOutOfBounds risk in unknown args check - Add unit tests (38 tests, 100% pass rate) Low Priority: - Remove artificial time.sleep() delays - Use Path.as_posix() for cross-platform paths - Add docstrings to all functions - Add __version__ and shebang - Optimize category matching with precomputed lookup dict - Add --dry-run mode for previewing changes Documentation: - Update README with new CLI options table - Add Development & Testing section - Add pyproject.toml for project configuration --- .gitignore | 44 +++++ README.md | 51 ++++- pyproject.toml | 71 +++++++ setup.py | 464 +++++++++++++++++++++++++++++++++++--------- tests/__init__.py | 1 + tests/test_setup.py | 350 +++++++++++++++++++++++++++++++++ 6 files changed, 887 insertions(+), 94 deletions(-) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_setup.py 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/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 2964a1a..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() + -# Advanced Heuristic Engine for Universal Categorization -DOMAIN_HEURISTICS = { +# ========================================== +# Domain Heuristics +# ========================================== + +DOMAIN_HEURISTICS: dict[str, list[str]] = { "security": [ "attack", "injection", @@ -493,14 +529,39 @@ class Colors: ], } +# 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 +} + + +# ========================================== +# Core Functions +# ========================================== + -def print_banner(): - print(f"\n{Colors.BOLD}{Colors.CYAN} ๐ŸŽฏ SkillPointer {Colors.ENDC}") +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 @@ -510,28 +571,39 @@ def get_category_for_skill(skill_name: str) -> str: 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( @@ -542,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] = [] - for folder in list(active_skills_dir.iterdir()): + # 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 folders: if not folder.is_dir(): continue @@ -565,49 +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(): - if dest.is_symlink() or dest.is_file(): - dest.unlink() + if dry_run: + print( + f"{Colors.WARNING} โš  Would replace existing: {dest}{Colors.ENDC}" + ) else: - shutil.rmtree(dest) + 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(dest)) + 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"\n{Colors.BLUE}โœ” Would migrate {moved_count} skills to {hidden_library_dir}{Colors.ENDC}\n" + ) + else: print( - f"{Colors.GREEN} ...and {moved_count - 5} more skills safely migrated.{Colors.ENDC}" + 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 = """--- @@ -636,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 @@ -652,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() @@ -660,35 +857,60 @@ 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." + 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 (opencode or claude)", + help="Target AI agent (default: opencode)", ) parser.add_argument( "--skill-dir", @@ -700,50 +922,110 @@ def main(): type=str, help="Directory to move skills to when creating pointers (overrides --agent default)", ) - args, unknown = parser.parse_known_args() + 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: - CONFIG["active_skills_dir"] = Path(args.skill_dir).expanduser().resolve() + 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: - CONFIG["hidden_library_dir"] = Path(args.vault_dir).expanduser().resolve() + 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"