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,229 @@
1
+ import type { SkillInfo, SkillSetInfo } from "../../types/index.js";
2
+
3
+ // ─── Item types ───────────────────────────────────────────────────────────────
4
+
5
+ export interface SkillCategoryItem {
6
+ id: string;
7
+ kind: "category";
8
+ label: string;
9
+ title: string;
10
+ categoryKey: string;
11
+ count?: number;
12
+ tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
13
+ badge?: string;
14
+ star?: string;
15
+ }
16
+
17
+ export interface SkillSkillItem {
18
+ id: string;
19
+ kind: "skill";
20
+ label: string;
21
+ skill: SkillInfo;
22
+ /** Extra indent level for child skills inside an expanded skill set */
23
+ indent?: number;
24
+ }
25
+
26
+ export interface SkillSetItem {
27
+ id: string;
28
+ kind: "skillset";
29
+ label: string;
30
+ skillSet: SkillSetInfo;
31
+ expanded: boolean;
32
+ }
33
+
34
+ export type SkillBrowserItem =
35
+ | SkillCategoryItem
36
+ | SkillSkillItem
37
+ | SkillSetItem;
38
+
39
+ // ─── Adapter ─────────────────────────────────────────────────────────────────
40
+
41
+ export interface BuildSkillBrowserItemsArgs {
42
+ recommended: SkillInfo[];
43
+ popular: SkillInfo[];
44
+ installed: SkillInfo[];
45
+ searchResults: SkillInfo[];
46
+ query: string;
47
+ isSearchLoading: boolean;
48
+ skillSets?: SkillSetInfo[];
49
+ expandedSets?: Set<string>;
50
+ }
51
+
52
+ /**
53
+ * Builds the flat list of items for the SkillsScreen list panel.
54
+ * Extracted from SkillsScreen so it can be tested independently.
55
+ */
56
+ export function buildSkillBrowserItems({
57
+ recommended,
58
+ popular,
59
+ installed,
60
+ searchResults,
61
+ query,
62
+ isSearchLoading,
63
+ skillSets = [],
64
+ expandedSets = new Set(),
65
+ }: BuildSkillBrowserItemsArgs): SkillBrowserItem[] {
66
+ const lowerQuery = query.toLowerCase();
67
+ const items: SkillBrowserItem[] = [];
68
+
69
+ // ── INSTALLED: always shown at top (if any) ──
70
+ const installedFiltered = lowerQuery
71
+ ? installed.filter((s) => s.name.toLowerCase().includes(lowerQuery))
72
+ : installed;
73
+
74
+ if (installedFiltered.length > 0) {
75
+ items.push({
76
+ id: "cat:installed",
77
+ kind: "category",
78
+ label: `Installed (${installedFiltered.length})`,
79
+ title: "Installed",
80
+ categoryKey: "installed",
81
+ count: installedFiltered.length,
82
+ tone: "purple",
83
+ star: "\u25CF ",
84
+ });
85
+ for (const skill of installedFiltered) {
86
+ items.push({
87
+ id: `skill:${skill.id}`,
88
+ kind: "skill",
89
+ label: skill.name,
90
+ skill,
91
+ });
92
+ }
93
+ }
94
+
95
+ // ── RECOMMENDED: skill sets + individual skills, all as first-class items ──
96
+ const filteredSets = lowerQuery
97
+ ? skillSets.filter((s) => {
98
+ if (s.name.toLowerCase().includes(lowerQuery)) return true;
99
+ if (s.description.toLowerCase().includes(lowerQuery)) return true;
100
+ if (
101
+ s.loaded &&
102
+ s.skills.some((sk) => sk.name.toLowerCase().includes(lowerQuery))
103
+ )
104
+ return true;
105
+ return false;
106
+ })
107
+ : skillSets;
108
+
109
+ const filteredRec = lowerQuery
110
+ ? recommended.filter(
111
+ (s) =>
112
+ s.name.toLowerCase().includes(lowerQuery) ||
113
+ (s.description || "").toLowerCase().includes(lowerQuery),
114
+ )
115
+ : recommended;
116
+
117
+ const recommendedCount = filteredSets.length + filteredRec.length;
118
+
119
+ items.push({
120
+ id: "cat:recommended",
121
+ kind: "category",
122
+ label: "Recommended",
123
+ title: "Recommended",
124
+ categoryKey: "recommended",
125
+ count: recommendedCount,
126
+ tone: "green",
127
+ star: "\u2605 ",
128
+ });
129
+
130
+ // Skill sets first within recommended
131
+ for (const set of filteredSets) {
132
+ const isExpanded = expandedSets.has(set.id);
133
+ items.push({
134
+ id: `skillset:${set.id}`,
135
+ kind: "skillset",
136
+ label: set.name,
137
+ skillSet: set,
138
+ expanded: isExpanded,
139
+ });
140
+ // When expanded, show child skills as indented skill items
141
+ if (isExpanded && set.loaded) {
142
+ const childSkills = lowerQuery
143
+ ? set.skills.filter((sk) => sk.name.toLowerCase().includes(lowerQuery))
144
+ : set.skills;
145
+ for (const skill of childSkills) {
146
+ items.push({
147
+ id: `skill:${skill.id}`,
148
+ kind: "skill",
149
+ label: skill.name,
150
+ skill,
151
+ indent: 2,
152
+ });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Then individual recommended skills
158
+ for (const skill of filteredRec) {
159
+ items.push({
160
+ id: `skill:${skill.id}`,
161
+ kind: "skill",
162
+ label: skill.name,
163
+ skill,
164
+ });
165
+ }
166
+
167
+ // ── SEARCH MODE ──
168
+ if (query.length >= 2) {
169
+ if (!isSearchLoading && searchResults.length > 0) {
170
+ const recNames = new Set(recommended.map((s) => s.name));
171
+ const deduped = searchResults
172
+ .filter((s) => !recNames.has(s.name))
173
+ .sort((a, b) => (b.stars ?? 0) - (a.stars ?? 0));
174
+
175
+ if (deduped.length > 0) {
176
+ items.push({
177
+ id: "cat:search",
178
+ kind: "category",
179
+ label: `Search (${deduped.length})`,
180
+ title: "Search",
181
+ categoryKey: "popular",
182
+ count: deduped.length,
183
+ tone: "teal",
184
+ });
185
+ for (const skill of deduped) {
186
+ items.push({
187
+ id: `skill:${skill.id}`,
188
+ kind: "skill",
189
+ label: skill.name,
190
+ skill,
191
+ });
192
+ }
193
+ }
194
+ }
195
+ return items;
196
+ }
197
+
198
+ // ── POPULAR (default, no search query) — only skills with meaningful stars ──
199
+ // Dedup by name — API can return same skill name from different repos
200
+ const seenPopular = new Set<string>();
201
+ const popularDeduped = popular
202
+ .filter((s) => (s.stars ?? 0) >= 5)
203
+ .filter((s) => {
204
+ if (seenPopular.has(s.name)) return false;
205
+ seenPopular.add(s.name);
206
+ return true;
207
+ });
208
+ if (popularDeduped.length > 0) {
209
+ items.push({
210
+ id: "cat:popular",
211
+ kind: "category",
212
+ label: "Popular",
213
+ title: "Popular",
214
+ categoryKey: "popular",
215
+ count: popularDeduped.length,
216
+ tone: "teal",
217
+ });
218
+ for (const skill of popularDeduped) {
219
+ items.push({
220
+ id: `skill:${skill.id}`,
221
+ kind: "skill",
222
+ label: skill.name,
223
+ skill,
224
+ });
225
+ }
226
+ }
227
+
228
+ return items;
229
+ }
@@ -0,0 +1,403 @@
1
+ import {
2
+ COMMUNITY_STYLES,
3
+ type CommunityStyle,
4
+ type CommunityStyleSource,
5
+ findCommunitySource,
6
+ } from "../../data/community-styles.js";
7
+ import type { CommunityUpstreamStatus } from "../../services/community-styles.js";
8
+ import type {
9
+ ImportedStyle,
10
+ StylePreset,
11
+ StyleSource,
12
+ } from "../../services/styles-manager.js";
13
+
14
+ // ─── Item types ───────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * List sections, in display order.
18
+ *
19
+ * Anthropic's official styles lead because they are the familiar starting
20
+ * point — someone who already knows Explanatory or Learning should find it
21
+ * first, then discover the presets that refine it. Team comes after the
22
+ * presets: it is the section a project fills in for itself.
23
+ *
24
+ * Community sorts LAST, and deliberately not second where "styles someone else
25
+ * wrote" would group it with Anthropic's. Two reasons decide it. It is the only
26
+ * section whose contents can change from the network, so keeping it at the
27
+ * bottom means the offline-authored sections never shift position underneath a
28
+ * user. And empty sections are omitted, so inserting it at position two would
29
+ * push Anthropic's familiar row down the screen for every existing user to buy
30
+ * discovery for a section most of them will not use.
31
+ */
32
+ export type StyleCategoryKey =
33
+ | "anthropic"
34
+ | "verbosity"
35
+ | "modifier"
36
+ | "team"
37
+ | "personal"
38
+ | "community";
39
+
40
+ export interface StyleCategoryItem {
41
+ id: string;
42
+ kind: "category";
43
+ label: string;
44
+ title: string;
45
+ categoryKey: StyleCategoryKey;
46
+ count: number;
47
+ tone: "purple" | "green" | "teal" | "yellow" | "gray" | "red";
48
+ badge?: string;
49
+ }
50
+
51
+ export interface StyleEntryItem {
52
+ id: string;
53
+ kind: "style";
54
+ label: string;
55
+ source: StyleSource;
56
+ /** In the pending selection (what apply would write). */
57
+ checked: boolean;
58
+ /** In the selection the generated style file records (what is live). */
59
+ applied: boolean;
60
+ /**
61
+ * Name of an already-selected style this one cancels out with, or null.
62
+ * Verbosity presets never report one — they behave as a radio group, so
63
+ * selecting a second replaces the first rather than colliding with it.
64
+ */
65
+ conflictsWith: string | null;
66
+ /** Template presets ship an empty table and cannot be applied directly. */
67
+ disabled: boolean;
68
+ /** For a fetched community style: what the last upstream check found. */
69
+ upstream?: CommunityUpstreamStatus;
70
+ }
71
+
72
+ /**
73
+ * A registry entry whose text is NOT on this machine yet.
74
+ *
75
+ * Not an `ImportedStyle`, and that is the point. `ImportedStyle` means "text
76
+ * that exists here"; a synthetic one carrying an empty body would be tickable,
77
+ * composable, and would hash to a lie. Keeping the type honest costs one extra
78
+ * item kind and buys a list where every tickable row has real words behind it.
79
+ */
80
+ export interface StyleOfferItem {
81
+ id: string;
82
+ kind: "offer";
83
+ label: string;
84
+ entry: CommunityStyle;
85
+ source: CommunityStyleSource;
86
+ /** True when the project's committed declaration asks for this one. */
87
+ declared: boolean;
88
+ }
89
+
90
+ export type StyleBrowserItem =
91
+ | StyleCategoryItem
92
+ | StyleEntryItem
93
+ | StyleOfferItem;
94
+
95
+ // ─── Adapter ──────────────────────────────────────────────────────────────────
96
+
97
+ export interface BuildStyleBrowserItemsArgs {
98
+ presets: StylePreset[];
99
+ imports: ImportedStyle[];
100
+ /** Pending selection, by `source.id`. */
101
+ selected: Set<string>;
102
+ /** Selection recorded in the generated style file (null if never applied). */
103
+ applied: { presets: string[]; imports: string[] } | null;
104
+ query: string;
105
+ /**
106
+ * Registry entries to offer. Defaults to the shipped registry, which is a
107
+ * compile-time constant, so building the list still costs no I/O whatsoever.
108
+ */
109
+ registry?: CommunityStyle[];
110
+ /** Last upstream check per coordinate id. Read from the local store only. */
111
+ upstream?: Record<string, CommunityUpstreamStatus>;
112
+ /** `community:` ids the declaration asks for and this machine lacks. */
113
+ fetchable?: string[];
114
+ }
115
+
116
+ function matches(source: StyleSource, lowerQuery: string): boolean {
117
+ if (!lowerQuery) return true;
118
+ const summary =
119
+ source.kind === "preset" ? source.summary : source.description;
120
+ // Filter on the displayed name too. A captured built-in shows as
121
+ // "Explanatory" and a titled preset as its full standard name; typing what
122
+ // you can see has to find it.
123
+ const shown = source.displayName;
124
+ return (
125
+ source.name.toLowerCase().includes(lowerQuery) ||
126
+ shown.toLowerCase().includes(lowerQuery) ||
127
+ summary.toLowerCase().includes(lowerQuery)
128
+ );
129
+ }
130
+
131
+ /** Same rule for an offer, over the fields an offer actually has. */
132
+ function offerMatches(entry: CommunityStyle, lowerQuery: string): boolean {
133
+ if (!lowerQuery) return true;
134
+ return (
135
+ entry.id.toLowerCase().includes(lowerQuery) ||
136
+ entry.displayName.toLowerCase().includes(lowerQuery) ||
137
+ entry.summary.toLowerCase().includes(lowerQuery)
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Which already-selected preset cancels `preset` out.
143
+ *
144
+ * `conflicts` is declared on one side of a pair in the shipped presets but the
145
+ * relationship is symmetric, so both directions are checked — otherwise the
146
+ * warning appears or not depending on which of the two the user ticked first.
147
+ */
148
+ function findConflict(
149
+ preset: StylePreset,
150
+ selectedPresets: StylePreset[],
151
+ ): string | null {
152
+ for (const other of selectedPresets) {
153
+ if (other.name === preset.name) continue;
154
+ if (
155
+ preset.conflicts.includes(other.name) ||
156
+ other.conflicts.includes(preset.name)
157
+ ) {
158
+ return other.name;
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Build the flat list for the Styles screen.
166
+ *
167
+ * Section order is Anthropic official, verbosity (exactly one), modifiers (any
168
+ * number), the project's own styles, this machine's, and finally other people's.
169
+ * Category rows are non-selectable headers — the screen's key handler skips
170
+ * them, and an empty section is omitted entirely rather than shown with a zero
171
+ * count.
172
+ */
173
+ export function buildStyleBrowserItems({
174
+ presets,
175
+ imports,
176
+ selected,
177
+ applied,
178
+ query,
179
+ registry = COMMUNITY_STYLES,
180
+ upstream = {},
181
+ fetchable = [],
182
+ }: BuildStyleBrowserItemsArgs): StyleBrowserItem[] {
183
+ const lowerQuery = query.trim().toLowerCase();
184
+ const items: StyleBrowserItem[] = [];
185
+
186
+ const appliedPresets = new Set(applied?.presets ?? []);
187
+ const appliedImports = new Set(applied?.imports ?? []);
188
+ const selectedPresets = presets.filter((preset) => selected.has(preset.id));
189
+
190
+ const verbosity = presets.filter((preset) => preset.axis === "verbosity");
191
+ const modifiers = presets.filter((preset) => preset.axis === "modifier");
192
+
193
+ const pushPresets = (
194
+ group: StylePreset[],
195
+ categoryKey: StyleCategoryKey,
196
+ title: string,
197
+ tone: StyleCategoryItem["tone"],
198
+ badge: string,
199
+ ) => {
200
+ const filtered = group.filter((preset) => matches(preset, lowerQuery));
201
+ if (filtered.length === 0) return;
202
+ items.push({
203
+ id: `cat:${categoryKey}`,
204
+ kind: "category",
205
+ label: title,
206
+ title,
207
+ categoryKey,
208
+ count: filtered.length,
209
+ tone,
210
+ badge,
211
+ });
212
+ for (const preset of filtered) {
213
+ items.push({
214
+ id: preset.id,
215
+ kind: "style",
216
+ label: preset.displayName,
217
+ source: preset,
218
+ checked: selected.has(preset.id),
219
+ applied: appliedPresets.has(preset.name),
220
+ conflictsWith:
221
+ preset.axis === "verbosity"
222
+ ? null
223
+ : findConflict(preset, selectedPresets),
224
+ disabled: preset.template,
225
+ });
226
+ }
227
+ };
228
+
229
+ const pushImports = (
230
+ origin: ImportedStyle["origin"],
231
+ categoryKey: StyleCategoryKey,
232
+ title: string,
233
+ tone: StyleCategoryItem["tone"],
234
+ badge: string,
235
+ ) => {
236
+ const filtered = imports.filter(
237
+ (style) => style.origin === origin && matches(style, lowerQuery),
238
+ );
239
+ if (filtered.length === 0) return;
240
+ items.push({
241
+ id: `cat:${categoryKey}`,
242
+ kind: "category",
243
+ label: title,
244
+ title,
245
+ categoryKey,
246
+ count: filtered.length,
247
+ tone,
248
+ badge,
249
+ });
250
+ for (const style of filtered) {
251
+ items.push({
252
+ id: style.id,
253
+ kind: "style",
254
+ // The displayed name, not the on-disk slug — "Explanatory", not
255
+ // "builtin-explanatory". `id` still carries the real one.
256
+ label: style.displayName,
257
+ source: style,
258
+ checked: selected.has(style.id),
259
+ applied: appliedImports.has(style.id),
260
+ conflictsWith: null,
261
+ disabled: false,
262
+ });
263
+ }
264
+ };
265
+
266
+ /**
267
+ * ONE stable order: registry order, by source then display name, whether a
268
+ * style is fetched or still an offer.
269
+ *
270
+ * Grouping fetched ones first was worse in use. Fetching moved the row out
271
+ * of the offers group and up into the fetched group, so everything below it
272
+ * shifted by one and the cursor — which tracks an index — ended up on a
273
+ * different style than the one just acted on. Position now says WHERE a
274
+ * style is in the registry and nothing else; whether it is on disk is the
275
+ * checkbox's job, which is the thing that can change without moving.
276
+ *
277
+ * Never ordered by popularity: the best-known names in this space ship no
278
+ * style files at all, so any such ranking would be one the registry cannot
279
+ * honour.
280
+ */
281
+ const pushCommunity = () => {
282
+ const declared = new Set(fetchable);
283
+ const fetched = imports.filter((style) => style.origin === "community");
284
+ // A community style's `name` IS its registry coordinate id, which is what
285
+ // lets one row hold either state without moving.
286
+ const onDisk = new Map(fetched.map((style) => [style.name, style]));
287
+
288
+ // Walk the registry in its own order and emit whichever state each entry
289
+ // is in. A fetched entry keeps the slot its offer occupied.
290
+ const rows = registry
291
+ .filter((entry) => !entry.retired || onDisk.has(entry.id))
292
+ .filter(
293
+ (entry) =>
294
+ findCommunitySource(entry.sourceId)?.retired !== true ||
295
+ onDisk.has(entry.id),
296
+ )
297
+ .filter((entry) => {
298
+ const style = onDisk.get(entry.id);
299
+ // Filter on whichever representation is showing, so typing a query
300
+ // cannot make a row vanish just because it changed state.
301
+ return style
302
+ ? matches(style, lowerQuery)
303
+ : offerMatches(entry, lowerQuery);
304
+ })
305
+ .sort(
306
+ (a, b) =>
307
+ a.sourceId.localeCompare(b.sourceId) ||
308
+ a.displayName.localeCompare(b.displayName),
309
+ );
310
+
311
+ // Anything on disk the registry no longer lists — a retired entry pulled
312
+ // before it was dropped. It still belongs to the user, so it is shown,
313
+ // after the registry rows so it cannot shift them.
314
+ const listed = new Set(rows.map((entry) => entry.id));
315
+ const orphans = fetched
316
+ .filter((style) => !listed.has(style.name))
317
+ .filter((style) => matches(style, lowerQuery))
318
+ .sort((a, b) => a.name.localeCompare(b.name));
319
+
320
+ if (rows.length === 0 && orphans.length === 0) return;
321
+
322
+ items.push({
323
+ id: "cat:community",
324
+ kind: "category",
325
+ label: "Community",
326
+ title: "Community",
327
+ categoryKey: "community",
328
+ count: rows.length + orphans.length,
329
+ // Yellow, shared with Anthropic captures: both sections are text
330
+ // captured from somewhere else onto this disk, which is the right
331
+ // pairing. Red is the only unused tone and it means danger here — a
332
+ // normal, working section must not wear it.
333
+ tone: "yellow",
334
+ badge: "from GitHub",
335
+ });
336
+
337
+ const pushFetched = (style: ImportedStyle) => {
338
+ items.push({
339
+ id: style.id,
340
+ kind: "style",
341
+ label: style.displayName,
342
+ source: style,
343
+ checked: selected.has(style.id),
344
+ applied: appliedImports.has(style.id),
345
+ conflictsWith: null,
346
+ disabled: false,
347
+ upstream: upstream[style.name],
348
+ });
349
+ };
350
+
351
+ for (const entry of rows) {
352
+ const style = onDisk.get(entry.id);
353
+ if (style) {
354
+ pushFetched(style);
355
+ continue;
356
+ }
357
+ const source = findCommunitySource(entry.sourceId);
358
+ if (!source) continue;
359
+ items.push({
360
+ id: `offer:${entry.id}`,
361
+ kind: "offer",
362
+ label: entry.displayName,
363
+ entry,
364
+ source,
365
+ declared: declared.has(`community:${entry.id}`),
366
+ });
367
+ }
368
+
369
+ for (const style of orphans) pushFetched(style);
370
+ };
371
+
372
+ // Badges are kept short deliberately: the row already carries a title and a
373
+ // count, and a long badge wraps onto a second line on a narrow pane, which
374
+ // costs a whole row and breaks the scan down the left column. The full
375
+ // explanation lives in the detail panel, where there is room for it.
376
+ pushImports(
377
+ "anthropic",
378
+ "anthropic",
379
+ "Anthropic official",
380
+ "yellow",
381
+ "captured",
382
+ );
383
+ pushPresets(verbosity, "verbosity", "Verbosity", "purple", "pick one");
384
+ pushPresets(modifiers, "modifier", "Modifiers", "teal", "combine freely");
385
+ pushImports("team", "team", "Team", "green", "in the repo");
386
+ pushImports("personal", "personal", "Personal", "gray", "this machine");
387
+ pushCommunity();
388
+
389
+ return items;
390
+ }
391
+
392
+ /**
393
+ * Index of the first row the user can actually land on, or 0 if none.
394
+ *
395
+ * `kind !== "category"` rather than `kind === "style"`: an offer is landable, it
396
+ * simply cannot be ticked. A cursor that skipped offers would make the whole
397
+ * section unreachable for a user who has fetched nothing — which is every user
398
+ * on their first launch.
399
+ */
400
+ export function firstSelectableIndex(items: StyleBrowserItem[]): number {
401
+ const index = items.findIndex((item) => item.kind !== "category");
402
+ return index >= 0 ? index : 0;
403
+ }
@@ -0,0 +1,64 @@
1
+ import React from "react";
2
+ import { type UiColor, theme } from "../theme.js";
3
+
4
+ interface CategoryHeaderProps {
5
+ /** Category title */
6
+ title: string;
7
+ /** Optional version to render after the title (e.g., "8.0.0" → " v8.0.0") */
8
+ version?: string;
9
+ /** Status badge (e.g., "✓ Configured", "3 plugins") */
10
+ status?: string;
11
+ /** Status badge color */
12
+ statusColor?: UiColor;
13
+ /** Whether category is expanded */
14
+ expanded?: boolean;
15
+ /** Number of items in category */
16
+ count?: number;
17
+ }
18
+
19
+ export function CategoryHeader({
20
+ title,
21
+ version,
22
+ status,
23
+ statusColor = theme.colors.success,
24
+ expanded = true,
25
+ count,
26
+ }: CategoryHeaderProps) {
27
+ const expandIcon = expanded ? "▼" : "▶";
28
+ const versionBadge = version ? ` v${version}` : "";
29
+ const countBadge = count !== undefined ? ` (${count})` : "";
30
+ const statusText = status ? ` ${status}` : "";
31
+
32
+ // Flex row instead of one flat <text>: a flat text wraps when the pane is
33
+ // narrower than the row ("by MadAppGang" lost its final character and the wrap
34
+ // shifted every item below by one line). Here the dash filler is the only
35
+ // element allowed to shrink to nothing, so the badge survives at any width
36
+ // and the title clips last.
37
+ return (
38
+ <box flexDirection="row" width="100%" height={1} overflow="hidden">
39
+ <box flexShrink={1} minWidth={4} overflow="hidden" height={1}>
40
+ <text fg={theme.colors.text}>
41
+ <span fg={theme.colors.muted}>{expandIcon}</span>
42
+ <span fg={theme.colors.text}>
43
+ <strong> {title}</strong>
44
+ </span>
45
+ <span fg={theme.colors.muted}>{versionBadge}</span>
46
+ <span fg={theme.colors.muted}>{countBadge}</span>
47
+ </text>
48
+ </box>
49
+ {/* flexShrink 100: the filler must be the first thing sacrificed. With equal
50
+ shrink factors yoga takes width from the (larger-basis) title first, which
51
+ clipped a character off the title while all four dashes survived. */}
52
+ <box flexShrink={100} minWidth={0} overflow="hidden" height={1}>
53
+ <text fg={theme.colors.border}> ────</text>
54
+ </box>
55
+ {status ? (
56
+ <box flexShrink={0} height={1}>
57
+ <text fg={statusColor}>{statusText}</text>
58
+ </box>
59
+ ) : null}
60
+ </box>
61
+ );
62
+ }
63
+
64
+ export default CategoryHeader;