magus-cli 0.0.0-placeholder → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,1111 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import {
5
+ type RecommendedSkill,
6
+ classifyStarReliability,
7
+ } from "../data/skill-repos.js";
8
+ import type {
9
+ GitTreeResponse,
10
+ SkillFrontmatter,
11
+ SkillInfo,
12
+ SkillSource,
13
+ } from "../types/index.js";
14
+ import {
15
+ getMarketplaceClonePath,
16
+ scanMcpServers,
17
+ } from "./local-marketplace.js";
18
+ import { ensureCheckout } from "./plugin-checkout.js";
19
+
20
+ const SKILLS_API_BASE =
21
+ "https://us-central1-claudish-6da10.cloudfunctions.net/skills";
22
+
23
+ // ─── In-process cache ─────────────────────────────────────────────────────────
24
+
25
+ interface TreeCacheEntry {
26
+ etag: string;
27
+ tree: GitTreeResponse;
28
+ fetchedAt: number;
29
+ }
30
+
31
+ const treeCache = new Map<string, TreeCacheEntry>();
32
+
33
+ // ─── Path helpers ──────────────────────────────────────────────────────────────
34
+
35
+ export function getUserSkillsDir(): string {
36
+ return path.join(os.homedir(), ".claude", "skills");
37
+ }
38
+
39
+ export function getProjectSkillsDir(projectPath?: string): string {
40
+ return path.join(projectPath ?? process.cwd(), ".claude", "skills");
41
+ }
42
+
43
+ // ─── GitHub Tree API ──────────────────────────────────────────────────────────
44
+
45
+ async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
46
+ const cached = treeCache.get(repo);
47
+
48
+ const url = `https://api.github.com/repos/${repo}/git/trees/HEAD?recursive=1`;
49
+ const headers: Record<string, string> = {
50
+ Accept: "application/vnd.github+json",
51
+ "X-GitHub-Api-Version": "2022-11-28",
52
+ };
53
+
54
+ if (cached?.etag) {
55
+ headers["If-None-Match"] = cached.etag;
56
+ }
57
+
58
+ const token =
59
+ process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
60
+ if (token) {
61
+ headers.Authorization = `Bearer ${token}`;
62
+ }
63
+
64
+ const response = await fetch(url, {
65
+ headers,
66
+ signal: AbortSignal.timeout(10000),
67
+ });
68
+
69
+ if (response.status === 304 && cached) {
70
+ return cached.tree;
71
+ }
72
+
73
+ if (response.status === 403 || response.status === 429) {
74
+ const resetHeader = response.headers.get("X-RateLimit-Reset");
75
+ const resetTime = resetHeader
76
+ ? new Date(Number(resetHeader) * 1000).toLocaleTimeString()
77
+ : "unknown";
78
+ throw new Error(
79
+ `GitHub API rate limit exceeded. Resets at ${resetTime}. Set GITHUB_TOKEN to increase limits.`,
80
+ );
81
+ }
82
+
83
+ if (!response.ok) {
84
+ throw new Error(
85
+ `GitHub API error: ${response.status} ${response.statusText}`,
86
+ );
87
+ }
88
+
89
+ const etag = response.headers.get("ETag") || "";
90
+ const tree = (await response.json()) as GitTreeResponse;
91
+
92
+ treeCache.set(repo, { etag, tree, fetchedAt: Date.now() });
93
+ return tree;
94
+ }
95
+
96
+ // ─── Skill Set fetching ──────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Fetch all skills from a skill set repo using the GitHub Tree API.
100
+ * Returns SkillInfo[] with installed status marked via disk scan.
101
+ */
102
+ export async function fetchSkillSetSkills(
103
+ repo: string,
104
+ projectPath?: string,
105
+ ): Promise<SkillInfo[]> {
106
+ const tree = await fetchGitTree(repo);
107
+
108
+ // Find all SKILL.md blobs
109
+ const skillBlobs = tree.tree.filter(
110
+ (entry) => entry.type === "blob" && entry.path.endsWith("/SKILL.md"),
111
+ );
112
+
113
+ const userInstalled = await getInstalledSkillNames("user");
114
+ const projectInstalled = await getInstalledSkillNames("project", projectPath);
115
+
116
+ const slugify = (name: string) =>
117
+ name
118
+ .toLowerCase()
119
+ .replace(/[^a-z0-9]+/g, "-")
120
+ .replace(/^-|-$/g, "");
121
+
122
+ const source: SkillSource = {
123
+ label: repo,
124
+ repo,
125
+ skillsPath: "",
126
+ };
127
+
128
+ return skillBlobs.map((blob) => {
129
+ // Derive skill name from parent directory of SKILL.md
130
+ const parts = blob.path.split("/");
131
+ const name = parts[parts.length - 2]; // e.g. "huggingface-datasets"
132
+ const repoPath = blob.path; // e.g. "skills/huggingface-datasets/SKILL.md"
133
+ const slug = slugify(name);
134
+
135
+ const isUser = userInstalled.has(slug) || userInstalled.has(name);
136
+ const isProj = projectInstalled.has(slug) || projectInstalled.has(name);
137
+ const installed = isUser || isProj;
138
+ const installedScope: "user" | "project" | null = isProj
139
+ ? "project"
140
+ : isUser
141
+ ? "user"
142
+ : null;
143
+
144
+ return {
145
+ id: `${repo}/${blob.path.replace("/SKILL.md", "")}`,
146
+ name,
147
+ source,
148
+ repoPath,
149
+ gitBlobSha: blob.sha,
150
+ frontmatter: null,
151
+ installed,
152
+ installedScope,
153
+ hasUpdate: false,
154
+ description: "",
155
+ };
156
+ });
157
+ }
158
+
159
+ // ─── Plugin contents listing ─────────────────────────────────────────────────
160
+
161
+ /** The four things a Claude Code plugin can ship. */
162
+ export type PluginComponentKind = "skill" | "command" | "agent" | "mcp";
163
+
164
+ /** One component found inside a plugin, for read-only display. */
165
+ export interface PluginComponent {
166
+ kind: PluginComponentKind;
167
+ /** Skill directory, command/agent file stem, or MCP server key. */
168
+ name: string;
169
+ /**
170
+ * Path within the repo. The SKILL.md / command .md / agent .md, or the
171
+ * `.mcp.json` that declares the server.
172
+ */
173
+ repoPath: string;
174
+ /**
175
+ * Sub-directory between the component root and the component itself, when
176
+ * the plugin groups them. "engineering" for skills/engineering/code-review.
177
+ */
178
+ group?: string;
179
+ /** MCP only: the command the server runs, for the detail pane. */
180
+ mcpCommand?: string;
181
+ }
182
+
183
+ /**
184
+ * Strip a marketplace `source` string down to a repo-relative directory.
185
+ * "./" and "" both mean the repo root.
186
+ */
187
+ function normalizePluginSource(source?: string): string {
188
+ if (!source) return "";
189
+ return source.replace(/^\.\//, "").replace(/\/+$/, "");
190
+ }
191
+
192
+ /** Sort key: group the four kinds, then alphabetise within each. */
193
+ const KIND_ORDER: Record<PluginComponentKind, number> = {
194
+ skill: 0,
195
+ command: 1,
196
+ agent: 2,
197
+ mcp: 3,
198
+ };
199
+
200
+ /**
201
+ * Collect `<root>/**\/*.md` under one component directory.
202
+ *
203
+ * Commands and agents are flat `.md` files, sometimes one directory deep for
204
+ * grouping. README.md is excluded: several plugins document a directory from
205
+ * inside it, and listing that as a command invites the user to run it.
206
+ */
207
+ function collectMarkdown(
208
+ paths: string[],
209
+ root: string,
210
+ kind: PluginComponentKind,
211
+ ): PluginComponent[] {
212
+ const out: PluginComponent[] = [];
213
+ for (const path of paths) {
214
+ if (!path.startsWith(root) || !path.endsWith(".md")) continue;
215
+ const inner = path.slice(root.length, -".md".length).split("/");
216
+ const name = inner[inner.length - 1];
217
+ if (!name || name.toLowerCase() === "readme") continue;
218
+ out.push({
219
+ kind,
220
+ name,
221
+ repoPath: path,
222
+ group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
223
+ });
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /**
229
+ * List everything a plugin ships, from the repo tree.
230
+ *
231
+ * Skills, commands and agents come from one tree read. MCP servers cost a
232
+ * second request, made only when the plugin actually carries a `.mcp.json` —
233
+ * the file has to be opened to learn the server *names*, and a plugin whose
234
+ * only content is an MCP server otherwise renders as empty. mnemex is exactly
235
+ * that shape: no skills, no commands, no agents, one server, and it appeared
236
+ * to ship nothing at all.
237
+ *
238
+ * Skill discovery is scoped deliberately to `<source>/skills/**` plus a
239
+ * `<source>/SKILL.md` for a plugin that IS one skill. Repos in the wild mirror
240
+ * their skills into every harness directory they support and into per-language
241
+ * docs trees: everything-claude-code carries 898 SKILL.md files but only 286
242
+ * distinct skills, the rest living under `.kiro/`, `.agents/`, `.cursor/` and
243
+ * `docs/<lang>/`. An unscoped walk lists all 898 and reads as the plugin being
244
+ * three times its real size. ponytail is the same shape at small scale: 6
245
+ * skills, mirrored into `.openclaw/skills/`.
246
+ */
247
+ export async function fetchPluginContents(
248
+ repo: string,
249
+ pluginSource?: string,
250
+ marketplaceName?: string,
251
+ ): Promise<PluginComponent[]> {
252
+ // Three sources, cheapest first.
253
+ //
254
+ // 1. Claude Code's marketplace clone. Free and complete — but only exists if
255
+ // the marketplace was actually cloned. "✓ Added" does not imply that: the
256
+ // badge means the catalog resolved, and a catalog resolves over HTTP.
257
+ // The short repo name is what a root-level SKILL.md is called, whichever
258
+ // source answers.
259
+ const rootSkillName = repo.split("/")[1] || repo;
260
+
261
+ if (marketplaceName) {
262
+ const local = await readLocalPluginContents(
263
+ getMarketplaceClonePath(marketplaceName),
264
+ pluginSource,
265
+ rootSkillName,
266
+ );
267
+ if (local) return local;
268
+ }
269
+
270
+ // 2. Our own read-only checkout. Unlimited and offline after the first fetch,
271
+ // and explicitly not an install — see plugin-checkout.ts. This is what
272
+ // keeps browsing working once the API budget is gone.
273
+ const checkout = await ensureCheckout(repo);
274
+ if (checkout) {
275
+ const cloned = await readLocalPluginContents(
276
+ checkout,
277
+ pluginSource,
278
+ rootSkillName,
279
+ );
280
+ if (cloned) return cloned;
281
+ }
282
+
283
+ // 3. The GitHub Tree API. Capped at 60 requests an hour across every repo on
284
+ // screen combined, so it is the last resort rather than the first.
285
+ const tree = await fetchGitTree(repo);
286
+ const base = normalizePluginSource(pluginSource);
287
+ const prefix = base ? `${base}/` : "";
288
+ const skillsPrefix = `${prefix}skills/`;
289
+
290
+ const blobs = tree.tree
291
+ .filter((entry) => entry.type === "blob")
292
+ .map((entry) => entry.path);
293
+
294
+ const components: PluginComponent[] = [];
295
+ const seenSkills = new Set<string>();
296
+
297
+ for (const path of blobs) {
298
+ // A plugin that is itself a single skill (blader/humanizer): one
299
+ // SKILL.md at the plugin root and no skills/ directory at all.
300
+ if (path === `${prefix}SKILL.md`) {
301
+ const name = base ? base.split("/").pop() || repo : repo.split("/")[1];
302
+ if (!seenSkills.has(name)) {
303
+ seenSkills.add(name);
304
+ components.push({ kind: "skill", name, repoPath: path });
305
+ }
306
+ continue;
307
+ }
308
+
309
+ if (!path.startsWith(skillsPrefix) || !path.endsWith("/SKILL.md")) continue;
310
+
311
+ // "skills/engineering/code-review/SKILL.md" → ["engineering", "code-review"]
312
+ const inner = path
313
+ .slice(skillsPrefix.length, -"/SKILL.md".length)
314
+ .split("/");
315
+ const name = inner[inner.length - 1];
316
+ if (!name || seenSkills.has(name)) continue;
317
+ seenSkills.add(name);
318
+
319
+ components.push({
320
+ kind: "skill",
321
+ name,
322
+ repoPath: path,
323
+ group: inner.length > 1 ? inner.slice(0, -1).join("/") : undefined,
324
+ });
325
+ }
326
+
327
+ components.push(
328
+ ...collectMarkdown(blobs, `${prefix}commands/`, "command"),
329
+ ...collectMarkdown(blobs, `${prefix}agents/`, "agent"),
330
+ );
331
+
332
+ const mcpPath = `${prefix}.mcp.json`;
333
+ if (blobs.includes(mcpPath)) {
334
+ components.push(...(await fetchMcpServers(repo, mcpPath)));
335
+ }
336
+
337
+ components.sort((a, b) => {
338
+ if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
339
+ const g = (a.group || "").localeCompare(b.group || "");
340
+ return g !== 0 ? g : a.name.localeCompare(b.name);
341
+ });
342
+ return components;
343
+ }
344
+
345
+ /**
346
+ * List a plugin's contents from the marketplace clone on disk.
347
+ *
348
+ * Returns null when there is no clone — the marketplace was never added, which
349
+ * is the normal state for a `featured` entry the user is only browsing. That
350
+ * case falls through to the tree API.
351
+ *
352
+ * The shape must match `fetchPluginContents`'s network path exactly, groups
353
+ * included, or a plugin would render differently depending on whether it
354
+ * happened to be installed.
355
+ */
356
+ export async function readLocalPluginContents(
357
+ repoRoot: string,
358
+ pluginSource?: string,
359
+ /**
360
+ * Name for a plugin that IS one skill at its root. Passed in rather than
361
+ * derived from `repoRoot`, whose basename differs per source: a marketplace
362
+ * clone is `magus`, a checkout is `blader__humanizer`. Deriving it made the
363
+ * same plugin render under two different names depending on which source
364
+ * answered.
365
+ */
366
+ rootSkillName?: string,
367
+ ): Promise<PluginComponent[] | null> {
368
+ const base = normalizePluginSource(pluginSource);
369
+ const pluginDir = path.join(repoRoot, ...(base ? base.split("/") : []));
370
+ if (!(await fs.pathExists(pluginDir))) return null;
371
+
372
+ const fallbackName = rootSkillName || path.basename(repoRoot);
373
+ const components: PluginComponent[] = [];
374
+ const rel = (abs: string) => {
375
+ const suffix = path.relative(repoRoot, abs);
376
+ return suffix.split(path.sep).join("/");
377
+ };
378
+
379
+ // A plugin that is itself a single skill: one SKILL.md at its root.
380
+ const rootSkill = path.join(pluginDir, "SKILL.md");
381
+ if (await fs.pathExists(rootSkill)) {
382
+ components.push({
383
+ kind: "skill",
384
+ name: base ? base.split("/").pop() || fallbackName : fallbackName,
385
+ repoPath: rel(rootSkill),
386
+ });
387
+ }
388
+
389
+ for (const skill of await walkSkillDirs(path.join(pluginDir, "skills"))) {
390
+ components.push({
391
+ kind: "skill",
392
+ name: skill.name,
393
+ repoPath: rel(path.join(skill.dir, "SKILL.md")),
394
+ group: skill.group,
395
+ });
396
+ }
397
+
398
+ for (const [dir, kind] of [
399
+ ["commands", "command"],
400
+ ["agents", "agent"],
401
+ ] as const) {
402
+ for (const file of await walkMarkdown(path.join(pluginDir, dir))) {
403
+ components.push({
404
+ kind,
405
+ name: file.name,
406
+ repoPath: rel(file.path),
407
+ group: file.group,
408
+ });
409
+ }
410
+ }
411
+
412
+ for (const name of await scanMcpServers(pluginDir)) {
413
+ components.push({
414
+ kind: "mcp",
415
+ name,
416
+ repoPath: rel(path.join(pluginDir, ".mcp.json")),
417
+ });
418
+ }
419
+
420
+ components.sort((a, b) => {
421
+ if (a.kind !== b.kind) return KIND_ORDER[a.kind] - KIND_ORDER[b.kind];
422
+ const g = (a.group || "").localeCompare(b.group || "");
423
+ return g !== 0 ? g : a.name.localeCompare(b.name);
424
+ });
425
+ return components;
426
+ }
427
+
428
+ /** Every directory under `root` that holds a SKILL.md, with its grouping path. */
429
+ export async function walkSkillDirs(
430
+ root: string,
431
+ group = "",
432
+ depth = 0,
433
+ ): Promise<Array<{ name: string; dir: string; group?: string }>> {
434
+ if (depth > 3 || !(await fs.pathExists(root))) return [];
435
+ let entries: import("node:fs").Dirent[];
436
+ try {
437
+ entries = await fs.readdir(root, { withFileTypes: true });
438
+ } catch {
439
+ return [];
440
+ }
441
+
442
+ const found: Array<{ name: string; dir: string; group?: string }> = [];
443
+ for (const entry of entries) {
444
+ if (!entry.isDirectory()) continue;
445
+ const child = path.join(root, entry.name);
446
+ if (await fs.pathExists(path.join(child, "SKILL.md"))) {
447
+ found.push({ name: entry.name, dir: child, group: group || undefined });
448
+ } else {
449
+ found.push(
450
+ ...(await walkSkillDirs(
451
+ child,
452
+ group ? `${group}/${entry.name}` : entry.name,
453
+ depth + 1,
454
+ )),
455
+ );
456
+ }
457
+ }
458
+ return found;
459
+ }
460
+
461
+ /** Markdown files under `root`, one level of grouping deep. README excluded. */
462
+ export async function walkMarkdown(
463
+ root: string,
464
+ group = "",
465
+ depth = 0,
466
+ ): Promise<Array<{ name: string; path: string; group?: string }>> {
467
+ if (depth > 2 || !(await fs.pathExists(root))) return [];
468
+ let entries: import("node:fs").Dirent[];
469
+ try {
470
+ entries = await fs.readdir(root, { withFileTypes: true });
471
+ } catch {
472
+ return [];
473
+ }
474
+
475
+ const found: Array<{ name: string; path: string; group?: string }> = [];
476
+ for (const entry of entries) {
477
+ const child = path.join(root, entry.name);
478
+ if (entry.isDirectory()) {
479
+ found.push(
480
+ ...(await walkMarkdown(
481
+ child,
482
+ group ? `${group}/${entry.name}` : entry.name,
483
+ depth + 1,
484
+ )),
485
+ );
486
+ continue;
487
+ }
488
+ if (!entry.name.endsWith(".md")) continue;
489
+ const name = entry.name.slice(0, -".md".length);
490
+ if (name.toLowerCase() === "readme") continue;
491
+ found.push({ name, path: child, group: group || undefined });
492
+ }
493
+ return found;
494
+ }
495
+
496
+ /**
497
+ * Read the server names out of a plugin's `.mcp.json`.
498
+ *
499
+ * Both shapes are in use: a bare `{ "<name>": {...} }` map, which mnemex
500
+ * writes, and the `{ "mcpServers": { "<name>": {...} } }` wrapper. A failure
501
+ * here degrades to a single unnamed entry rather than throwing — the plugin
502
+ * demonstrably has a server, and losing the whole listing over its name would
503
+ * be a worse answer than showing it without one.
504
+ */
505
+ async function fetchMcpServers(
506
+ repo: string,
507
+ mcpPath: string,
508
+ ): Promise<PluginComponent[]> {
509
+ try {
510
+ const headers: Record<string, string> = {
511
+ Accept: "application/vnd.github.raw",
512
+ "X-GitHub-Api-Version": "2022-11-28",
513
+ };
514
+ const token =
515
+ process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
516
+ if (token) headers.Authorization = `Bearer ${token}`;
517
+
518
+ const response = await fetch(
519
+ `https://api.github.com/repos/${repo}/contents/${mcpPath}`,
520
+ { headers, signal: AbortSignal.timeout(10000) },
521
+ );
522
+ if (!response.ok) throw new Error(String(response.status));
523
+
524
+ const raw = JSON.parse(await response.text()) as Record<string, unknown>;
525
+ const servers = (
526
+ raw.mcpServers && typeof raw.mcpServers === "object"
527
+ ? raw.mcpServers
528
+ : raw
529
+ ) as Record<string, { command?: string; args?: string[] }>;
530
+
531
+ const named = Object.entries(servers)
532
+ .filter(([, cfg]) => cfg && typeof cfg === "object")
533
+ .map(([name, cfg]) => ({
534
+ kind: "mcp" as const,
535
+ name,
536
+ repoPath: mcpPath,
537
+ mcpCommand: [cfg.command, ...(cfg.args ?? [])]
538
+ .filter(Boolean)
539
+ .join(" "),
540
+ }));
541
+
542
+ return named.length > 0
543
+ ? named
544
+ : [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
545
+ } catch {
546
+ return [{ kind: "mcp", name: "MCP server", repoPath: mcpPath }];
547
+ }
548
+ }
549
+
550
+ // ─── Plugin skill content ────────────────────────────────────────────────────
551
+
552
+ /** A skill's own SKILL.md, split into its frontmatter and its body. */
553
+ export interface PluginSkillDetail {
554
+ /** Every frontmatter key, in the order the file declares them. */
555
+ frontmatter: Array<{ key: string; value: string }>;
556
+ /** Markdown after the frontmatter block, trailing whitespace trimmed. */
557
+ body: string;
558
+ /** Total body lines, so a truncated render can say what it withheld. */
559
+ bodyLines: number;
560
+ }
561
+
562
+ /**
563
+ * Read one skill's SKILL.md.
564
+ *
565
+ * Served from the contents API rather than raw.githubusercontent.com, for two
566
+ * reasons: it is the host `fetchGitTree` already uses, so GITHUB_TOKEN raises
567
+ * the limit for both at once, and the raw host is the one a Tailscale MagicDNS
568
+ * search domain hijacks — which fails as an empty body rather than an error.
569
+ */
570
+ export async function fetchPluginSkillDetail(
571
+ repo: string,
572
+ repoPath: string,
573
+ ): Promise<PluginSkillDetail> {
574
+ const headers: Record<string, string> = {
575
+ Accept: "application/vnd.github.raw",
576
+ "X-GitHub-Api-Version": "2022-11-28",
577
+ };
578
+ const token =
579
+ process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
580
+ if (token) headers.Authorization = `Bearer ${token}`;
581
+
582
+ const response = await fetch(
583
+ `https://api.github.com/repos/${repo}/contents/${repoPath}`,
584
+ { headers, signal: AbortSignal.timeout(10000) },
585
+ );
586
+
587
+ if (response.status === 403 || response.status === 429) {
588
+ throw new Error(
589
+ "GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase limits.",
590
+ );
591
+ }
592
+ if (!response.ok) {
593
+ throw new Error(
594
+ `GitHub API error: ${response.status} ${response.statusText}`,
595
+ );
596
+ }
597
+
598
+ return splitSkillDocument(await response.text());
599
+ }
600
+
601
+ /**
602
+ * Split a SKILL.md into ordered frontmatter pairs and its body.
603
+ *
604
+ * Ordered pairs rather than an object because the panel renders them as written
605
+ * — `name` and `description` first is the near-universal convention, and
606
+ * re-sorting them would bury the description under whatever keys sort earlier.
607
+ * List values are flattened to a comma-joined string for display.
608
+ */
609
+ export function splitSkillDocument(content: string): PluginSkillDetail {
610
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/);
611
+ const frontmatter: Array<{ key: string; value: string }> = [];
612
+
613
+ if (match) {
614
+ let pendingKey: string | null = null;
615
+ for (const line of match[1].split("\n")) {
616
+ // A " - item" line continues the list opened by the previous key.
617
+ const listItem = line.match(/^\s*-\s+(.*)$/);
618
+ if (listItem && pendingKey) {
619
+ const last = frontmatter[frontmatter.length - 1];
620
+ last.value = last.value
621
+ ? `${last.value}, ${stripQuotes(listItem[1])}`
622
+ : stripQuotes(listItem[1]);
623
+ continue;
624
+ }
625
+
626
+ const colonIdx = line.indexOf(":");
627
+ if (colonIdx === -1) continue;
628
+ const key = line.slice(0, colonIdx).trim();
629
+ if (!key || key.startsWith("#")) continue;
630
+
631
+ const raw = line.slice(colonIdx + 1).trim();
632
+ pendingKey = key;
633
+ frontmatter.push({
634
+ key,
635
+ value:
636
+ raw.startsWith("[") && raw.endsWith("]")
637
+ ? raw
638
+ .slice(1, -1)
639
+ .split(",")
640
+ .map((s) => stripQuotes(s.trim()))
641
+ .filter(Boolean)
642
+ .join(", ")
643
+ : stripQuotes(raw),
644
+ });
645
+ }
646
+ }
647
+
648
+ const body = content.slice(match ? match[0].length : 0).trimEnd();
649
+ return {
650
+ frontmatter,
651
+ body,
652
+ bodyLines: body ? body.split("\n").length : 0,
653
+ };
654
+ }
655
+
656
+ function stripQuotes(value: string): string {
657
+ return value.replace(/^["']|["']$/g, "");
658
+ }
659
+
660
+ // ─── Frontmatter parser ───────────────────────────────────────────────────────
661
+
662
+ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
663
+ const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
664
+ if (!frontmatterMatch) return {};
665
+
666
+ const yaml = frontmatterMatch[1];
667
+ const result: Record<string, unknown> = {};
668
+
669
+ for (const line of yaml.split("\n")) {
670
+ const colonIdx = line.indexOf(":");
671
+ if (colonIdx === -1) continue;
672
+
673
+ const key = line.slice(0, colonIdx).trim();
674
+ const rawValue = line.slice(colonIdx + 1).trim();
675
+
676
+ if (!key) continue;
677
+
678
+ // Handle arrays (simple inline: [a, b, c])
679
+ if (rawValue.startsWith("[") && rawValue.endsWith("]")) {
680
+ const items = rawValue
681
+ .slice(1, -1)
682
+ .split(",")
683
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
684
+ .filter(Boolean);
685
+ result[key] = items;
686
+ } else {
687
+ // Strip quotes
688
+ result[key] = rawValue.replace(/^["']|["']$/g, "");
689
+ }
690
+ }
691
+
692
+ return result as Partial<SkillFrontmatter>;
693
+ }
694
+
695
+ export async function fetchSkillFrontmatter(
696
+ skill: SkillInfo,
697
+ ): Promise<SkillFrontmatter> {
698
+ const url = `https://raw.githubusercontent.com/${skill.source.repo}/HEAD/${skill.repoPath}`;
699
+
700
+ const response = await fetch(url, {
701
+ signal: AbortSignal.timeout(10000),
702
+ });
703
+
704
+ if (!response.ok) {
705
+ return {
706
+ name: skill.name,
707
+ description: "(no description)",
708
+ category: "general",
709
+ };
710
+ }
711
+
712
+ const content = await response.text();
713
+ const parsed = parseYamlFrontmatter(content);
714
+
715
+ return {
716
+ name: parsed.name || skill.name,
717
+ description: parsed.description || "(no description)",
718
+ category: parsed.category,
719
+ author: parsed.author,
720
+ version: parsed.version,
721
+ tags: parsed.tags,
722
+ };
723
+ }
724
+
725
+ // ─── Check installation ───────────────────────────────────────────────────────
726
+
727
+ export async function getInstalledSkillNames(
728
+ scope: "user" | "project",
729
+ projectPath?: string,
730
+ ): Promise<Set<string>> {
731
+ const dir =
732
+ scope === "user" ? getUserSkillsDir() : getProjectSkillsDir(projectPath);
733
+
734
+ const installed = new Set<string>();
735
+
736
+ try {
737
+ if (!(await fs.pathExists(dir))) return installed;
738
+ const entries = await fs.readdir(dir);
739
+ for (const entry of entries) {
740
+ const skillMd = path.join(dir, entry, "SKILL.md");
741
+ if (await fs.pathExists(skillMd)) {
742
+ installed.add(entry);
743
+ }
744
+ }
745
+ } catch {
746
+ // ignore
747
+ }
748
+
749
+ return installed;
750
+ }
751
+
752
+ // ─── Firebase Skills API ──────────────────────────────────────────────────────
753
+
754
+ function mapApiSkillToSkillInfo(entry: unknown): SkillInfo {
755
+ const raw: Record<string, unknown> =
756
+ typeof entry === "object" && entry !== null
757
+ ? (entry as Record<string, unknown>)
758
+ : {};
759
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
760
+ const repo = str(raw.repo) || "unknown";
761
+ const skillPath = str(raw.skillPath) || str(raw.name) || "";
762
+ const source: SkillSource = {
763
+ label: repo,
764
+ repo,
765
+ skillsPath: "",
766
+ };
767
+ const stars = typeof raw.stars === "number" ? raw.stars : undefined;
768
+ return {
769
+ id: `${repo}/${skillPath}`,
770
+ name: str(raw.name) || skillPath,
771
+ description: str(raw.description),
772
+ source,
773
+ repoPath: skillPath ? `${skillPath}/SKILL.md` : "SKILL.md",
774
+ gitBlobSha: "",
775
+ frontmatter: null,
776
+ installed: false,
777
+ installedScope: null,
778
+ hasUpdate: false,
779
+ stars,
780
+ starReliability: classifyStarReliability(repo, stars),
781
+ };
782
+ }
783
+
784
+ export async function fetchPopularSkills(limit = 30): Promise<SkillInfo[]> {
785
+ try {
786
+ const res = await fetch(
787
+ `${SKILLS_API_BASE}/search?q=development&limit=${limit}&sortBy=stars`,
788
+ { signal: AbortSignal.timeout(10000) },
789
+ );
790
+ if (!res.ok) return [];
791
+ const data = (await res.json()) as { skills?: unknown[] };
792
+ return (data.skills || []).map(mapApiSkillToSkillInfo);
793
+ } catch {
794
+ return [];
795
+ }
796
+ }
797
+
798
+ // ─── Fetch available skills ───────────────────────────────────────────────────
799
+
800
+ export async function fetchAvailableSkills(
801
+ _repos: SkillSource[],
802
+ projectPath?: string,
803
+ recommended: RecommendedSkill[] = [],
804
+ ): Promise<SkillInfo[]> {
805
+ const userInstalled = await getInstalledSkillNames("user");
806
+ const projectInstalled = await getInstalledSkillNames("project", projectPath);
807
+
808
+ const slugify = (name: string) =>
809
+ name
810
+ .toLowerCase()
811
+ .replace(/[^a-z0-9]+/g, "-")
812
+ .replace(/^-|-$/g, "");
813
+
814
+ const markInstalled = (skill: SkillInfo): SkillInfo => {
815
+ const slug = slugify(skill.name);
816
+ const isUserInstalled =
817
+ userInstalled.has(slug) || userInstalled.has(skill.name);
818
+ const isProjInstalled =
819
+ projectInstalled.has(slug) || projectInstalled.has(skill.name);
820
+ const installed = isUserInstalled || isProjInstalled;
821
+ const installedScope: "user" | "project" | null = isProjInstalled
822
+ ? "project"
823
+ : isUserInstalled
824
+ ? "user"
825
+ : null;
826
+ return { ...skill, installed, installedScope };
827
+ };
828
+
829
+ // 1. Recommended skills from the versioned models-index catalog.
830
+ const recommendedSkills: SkillInfo[] = recommended.map((rec) => {
831
+ const source: SkillSource = {
832
+ label: rec.repo,
833
+ repo: rec.repo,
834
+ skillsPath: "",
835
+ };
836
+ const skill: SkillInfo = {
837
+ id: `${rec.repo}/${rec.skillPath}`,
838
+ name: rec.name,
839
+ description: rec.description,
840
+ source,
841
+ repoPath: `${rec.skillPath}/SKILL.md`,
842
+ gitBlobSha: "",
843
+ frontmatter: null,
844
+ installed: false,
845
+ installedScope: null,
846
+ hasUpdate: false,
847
+ isRecommended: true,
848
+ starReliability: classifyStarReliability(rec.repo, rec.stars),
849
+ };
850
+ return markInstalled(skill);
851
+ });
852
+
853
+ // 2. Fetch popular skills from Firebase API
854
+ const popular = await fetchPopularSkills(30);
855
+ const popularSkills = popular.map((s) =>
856
+ markInstalled({ ...s, isRecommended: false }),
857
+ );
858
+
859
+ // 3. Combine: recommended first, then popular (dedup by name)
860
+ const seen = new Set<string>(recommendedSkills.map((s) => s.name));
861
+ const deduped = popularSkills.filter((s) => !seen.has(s.name));
862
+
863
+ return [...recommendedSkills, ...deduped];
864
+ }
865
+
866
+ // ─── Install / Uninstall ──────────────────────────────────────────────────────
867
+
868
+ /** Hard ceilings so one pathological repo cannot fill a user's disk. */
869
+ const MAX_SKILL_FILES = 200;
870
+ const MAX_SKILL_BYTES = 20 * 1024 * 1024;
871
+
872
+ /**
873
+ * Pick the files that belong to one skill out of a whole-repo tree listing.
874
+ *
875
+ * Returns paths relative to the skill directory, so `skills/x/refs/a.md`
876
+ * under `skills/x` becomes `refs/a.md`.
877
+ *
878
+ * The trailing slash on the prefix is load-bearing: without it `skills/audit`
879
+ * also matches `skills/audit-website/...`, and the user gets a second skill's
880
+ * files dumped into the first one's directory.
881
+ */
882
+ export function selectSkillFiles(
883
+ treePaths: { path: string; type: string }[],
884
+ repoPath: string,
885
+ ): string[] {
886
+ const prefix = `${repoPath.replace(/\/+$/, "")}/`;
887
+ return treePaths
888
+ .filter((e) => e.type === "blob" && e.path.startsWith(prefix))
889
+ .map((e) => e.path.slice(prefix.length))
890
+ .filter((rel) => rel.length > 0);
891
+ }
892
+
893
+ /**
894
+ * Resolve one relative path inside the install directory, or return null.
895
+ *
896
+ * These paths come from a remote repository, so they are untrusted input to a
897
+ * filesystem write. Anything that escapes the install directory — `..`
898
+ * segments, an absolute path — is dropped rather than written outside the
899
+ * skill folder.
900
+ */
901
+ export function resolveSkillFilePath(
902
+ installDir: string,
903
+ relativePath: string,
904
+ ): string | null {
905
+ if (!relativePath || path.isAbsolute(relativePath)) return null;
906
+ const resolvedDir = path.resolve(installDir);
907
+ const target = path.resolve(resolvedDir, relativePath);
908
+ if (target !== resolvedDir && !target.startsWith(resolvedDir + path.sep)) {
909
+ return null;
910
+ }
911
+ return target;
912
+ }
913
+
914
+ /** What an install actually delivered — surfaced so the UI can be honest. */
915
+ export interface InstallResult {
916
+ /** Number of files written, always ≥ 1 (SKILL.md). */
917
+ fileCount: number;
918
+ /**
919
+ * Set when only SKILL.md could be written for a skill that has more files,
920
+ * e.g. the GitHub tree call was rate-limited. The skill is installed but
921
+ * incomplete, and the caller should say so.
922
+ */
923
+ degraded?: string;
924
+ }
925
+
926
+ export async function installSkill(
927
+ skill: SkillInfo,
928
+ scope: "user" | "project",
929
+ projectPath?: string,
930
+ ): Promise<InstallResult> {
931
+ // Try multiple URL patterns — repos structure SKILL.md differently
932
+ const repo = skill.source.repo;
933
+ const repoPath = skill.repoPath.replace(/\/SKILL\.md$/, "");
934
+ const candidates = [
935
+ `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/SKILL.md`,
936
+ `https://raw.githubusercontent.com/${repo}/main/${repoPath}/SKILL.md`,
937
+ `https://raw.githubusercontent.com/${repo}/master/${repoPath}/SKILL.md`,
938
+ `https://raw.githubusercontent.com/${repo}/HEAD/SKILL.md`,
939
+ `https://raw.githubusercontent.com/${repo}/main/SKILL.md`,
940
+ ];
941
+
942
+ let content: string | null = null;
943
+ for (const url of candidates) {
944
+ try {
945
+ const response = await fetch(url, { signal: AbortSignal.timeout(8000) });
946
+ if (response.ok) {
947
+ content = await response.text();
948
+ break;
949
+ }
950
+ } catch {}
951
+ }
952
+
953
+ if (!content) {
954
+ throw new Error(
955
+ `Failed to fetch skill: SKILL.md not found in ${repo}/${repoPath}`,
956
+ );
957
+ }
958
+
959
+ // Use slug for directory name — display names can have slashes/spaces
960
+ const dirName = skill.name
961
+ .toLowerCase()
962
+ .replace(/[^a-z0-9]+/g, "-")
963
+ .replace(/^-|-$/g, "");
964
+
965
+ const installDir =
966
+ scope === "user"
967
+ ? path.join(getUserSkillsDir(), dirName)
968
+ : path.join(getProjectSkillsDir(projectPath), dirName);
969
+
970
+ await fs.ensureDir(installDir);
971
+ await fs.writeFile(path.join(installDir, "SKILL.md"), content, "utf8");
972
+
973
+ // SKILL.md alone is usually not a working skill. Skills ship scripts,
974
+ // references and assets beside it and link to them by relative path, so
975
+ // writing only SKILL.md leaves dangling links — measured: audit-website
976
+ // references `references/OUTPUT-FORMAT.md`, systematic-debugging ships
977
+ // `find-polluter.sh` and 9 more. Fetch the rest of the directory.
978
+ let extras: string[] = [];
979
+ try {
980
+ const tree = await fetchGitTree(repo);
981
+ extras = selectSkillFiles(tree.tree, repoPath).filter(
982
+ (rel) => rel !== "SKILL.md",
983
+ );
984
+ } catch (error) {
985
+ // The tree call is the rate-limited one. A skill with only SKILL.md is
986
+ // still correct, so report the shortfall instead of failing the install.
987
+ return {
988
+ fileCount: 1,
989
+ degraded:
990
+ error instanceof Error && /rate limit/i.test(error.message)
991
+ ? "GitHub rate limit — supporting files not fetched. Set GITHUB_TOKEN and reinstall."
992
+ : "supporting files could not be listed; SKILL.md only",
993
+ };
994
+ }
995
+
996
+ if (extras.length === 0) return { fileCount: 1 };
997
+
998
+ const truncated = extras.length > MAX_SKILL_FILES;
999
+ const selected = truncated ? extras.slice(0, MAX_SKILL_FILES) : extras;
1000
+
1001
+ let written = 1;
1002
+ let bytes = content.length;
1003
+ let budgetHit = false;
1004
+
1005
+ for (const rel of selected) {
1006
+ if (bytes >= MAX_SKILL_BYTES) {
1007
+ budgetHit = true;
1008
+ break;
1009
+ }
1010
+ const target = resolveSkillFilePath(installDir, rel);
1011
+ if (!target) continue; // escapes the install dir — never write it
1012
+ try {
1013
+ const res = await fetch(
1014
+ `https://raw.githubusercontent.com/${repo}/HEAD/${repoPath}/${rel}`,
1015
+ { signal: AbortSignal.timeout(15000) },
1016
+ );
1017
+ if (!res.ok) continue;
1018
+ // Buffer, not text: skills ship binary assets (.png, .svg) and
1019
+ // decoding those as UTF-8 corrupts them.
1020
+ const buf = Buffer.from(await res.arrayBuffer());
1021
+ await fs.ensureDir(path.dirname(target));
1022
+ await fs.writeFile(target, buf);
1023
+ written++;
1024
+ bytes += buf.length;
1025
+ } catch {
1026
+ // One unreachable asset should not abandon a half-written skill.
1027
+ }
1028
+ }
1029
+
1030
+ let degraded: string | undefined;
1031
+ if (budgetHit) {
1032
+ degraded = `stopped at ${MAX_SKILL_BYTES / 1024 / 1024}MB — skill is larger than the install budget`;
1033
+ } else if (truncated) {
1034
+ degraded = `only the first ${MAX_SKILL_FILES} of ${extras.length + 1} files were installed`;
1035
+ } else if (written < selected.length + 1) {
1036
+ degraded = `${selected.length + 1 - written} of ${selected.length + 1} files could not be fetched`;
1037
+ }
1038
+
1039
+ return { fileCount: written, degraded };
1040
+ }
1041
+
1042
+ export async function uninstallSkill(
1043
+ skillName: string,
1044
+ scope: "user" | "project",
1045
+ projectPath?: string,
1046
+ ): Promise<void> {
1047
+ const dirName = skillName
1048
+ .toLowerCase()
1049
+ .replace(/[^a-z0-9]+/g, "-")
1050
+ .replace(/^-|-$/g, "");
1051
+
1052
+ const installDir =
1053
+ scope === "user"
1054
+ ? path.join(getUserSkillsDir(), dirName)
1055
+ : path.join(getProjectSkillsDir(projectPath), dirName);
1056
+
1057
+ const skillMdPath = path.join(installDir, "SKILL.md");
1058
+
1059
+ // Remove the whole skill directory, not just SKILL.md. Installs now write
1060
+ // supporting files (scripts, references, assets); deleting SKILL.md alone
1061
+ // left those orphaned, and the follow-up `rmdir` then failed silently on the
1062
+ // non-empty directory — so the skill vanished from the UI while its files
1063
+ // stayed on disk forever.
1064
+ //
1065
+ // Gated on SKILL.md existing so this only ever deletes a directory that is
1066
+ // actually an installed skill.
1067
+ if (await fs.pathExists(skillMdPath)) {
1068
+ await fs.remove(installDir);
1069
+ return;
1070
+ }
1071
+
1072
+ // No SKILL.md: at most an empty leftover directory. Remove it only if empty.
1073
+ try {
1074
+ await fs.rmdir(installDir);
1075
+ } catch {
1076
+ // Ignore if not empty or doesn't exist
1077
+ }
1078
+ }
1079
+
1080
+ // ─── Check installed skills from file system ──────────────────────────────────
1081
+
1082
+ export async function getInstalledSkillsFromFs(
1083
+ projectPath?: string,
1084
+ ): Promise<{ name: string; scope: "user" | "project" }[]> {
1085
+ const result: { name: string; scope: "user" | "project" }[] = [];
1086
+
1087
+ const userDir = getUserSkillsDir();
1088
+ const projDir = getProjectSkillsDir(projectPath);
1089
+
1090
+ const scopedDirs: Array<[string, "user" | "project"]> = [
1091
+ [userDir, "user"],
1092
+ [projDir, "project"],
1093
+ ];
1094
+
1095
+ for (const [dir, scope] of scopedDirs) {
1096
+ try {
1097
+ if (!(await fs.pathExists(dir))) continue;
1098
+ const entries = await fs.readdir(dir);
1099
+ for (const entry of entries) {
1100
+ const skillMd = path.join(dir, entry, "SKILL.md");
1101
+ if (await fs.pathExists(skillMd)) {
1102
+ result.push({ name: entry, scope });
1103
+ }
1104
+ }
1105
+ } catch {
1106
+ // ignore
1107
+ }
1108
+ }
1109
+
1110
+ return result;
1111
+ }