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,164 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type React from "react";
3
+ import { ScopeSquares } from "../ui/components/primitives/ScopeSquares.js";
4
+ import {
5
+ SCOPE_OFF_FILL,
6
+ resetThemeMode,
7
+ setThemeMode,
8
+ } from "../ui/theme-mode.js";
9
+ import { CONTRAST_REFERENCE, theme } from "../ui/theme.js";
10
+
11
+ /**
12
+ * The scope bar is three filled segments; only the fill says lit or unlit.
13
+ *
14
+ * Its unlit fill was `theme.colors.muted` (#6B7280) — the same mid-dark grey as
15
+ * de-emphasised text. On a light page that is a *dark* block, so an unlit segment
16
+ * carried the same visual weight as a lit one and the bar read as three similar
17
+ * blobs: "non installed state is dark as well - confusing".
18
+ *
19
+ * An unlit segment has to recede toward the page, which is exactly what a fixed
20
+ * colour cannot do — a pale fill that sits quietly on cream is a bright block on
21
+ * near-black. So the fill follows the resolved mode, and these tests pin both
22
+ * that behaviour and the dark default that applies before resolution.
23
+ */
24
+
25
+ interface SpanLike {
26
+ props: { fg: string; children: string };
27
+ }
28
+
29
+ function segments(el: React.ReactElement): SpanLike[] {
30
+ const children = (el.props as { children: unknown }).children;
31
+ return (Array.isArray(children) ? children : [children]) as SpanLike[];
32
+ }
33
+
34
+ const glyphs = (el: React.ReactElement) =>
35
+ segments(el).map((c) => c.props.children);
36
+ const fills = (el: React.ReactElement) => segments(el).map((c) => c.props.fg);
37
+
38
+ function relativeLuminance(hex: string): number {
39
+ const n = hex.replace("#", "");
40
+ const ch = [0, 2, 4].map((i) => Number.parseInt(n.slice(i, i + 2), 16) / 255);
41
+ const lin = ch.map((c) =>
42
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
43
+ );
44
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
45
+ }
46
+
47
+ afterEach(() => resetThemeMode());
48
+
49
+ describe("every segment is a filled block", () => {
50
+ test("lit and unlit alike use the filled glyph", () => {
51
+ setThemeMode("light");
52
+ const el = ScopeSquares({ user: true, project: false, local: true });
53
+ expect(glyphs(el)).toEqual(["■", "■", "■"]);
54
+ });
55
+
56
+ test("local is omitted when the caller does not pass it", () => {
57
+ setThemeMode("light");
58
+ expect(glyphs(ScopeSquares({ user: true, project: false }))).toEqual([
59
+ "■",
60
+ "■",
61
+ ]);
62
+ });
63
+ });
64
+
65
+ describe("fill distinguishes lit from unlit", () => {
66
+ test("each lit segment keeps its own scope hue", () => {
67
+ setThemeMode("light");
68
+ const el = ScopeSquares({ user: true, project: true, local: true });
69
+ expect(fills(el)).toEqual([
70
+ theme.scopes.user,
71
+ theme.scopes.project,
72
+ theme.scopes.local,
73
+ ]);
74
+ });
75
+
76
+ test("unlit segments take the page-relative off fill", () => {
77
+ setThemeMode("light");
78
+ const el = ScopeSquares({ user: false, project: false, local: false });
79
+ expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.light]));
80
+
81
+ setThemeMode("dark");
82
+ const dark = ScopeSquares({ user: false, project: false, local: false });
83
+ expect(new Set(fills(dark))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
84
+ });
85
+
86
+ test("a mixed bar shows lit and unlit fills side by side", () => {
87
+ setThemeMode("light");
88
+ const el = ScopeSquares({ user: false, project: true, local: false });
89
+ expect(fills(el)).toEqual([
90
+ SCOPE_OFF_FILL.light,
91
+ theme.scopes.project,
92
+ SCOPE_OFF_FILL.light,
93
+ ]);
94
+ });
95
+
96
+ test("before resolution the fill is the dark pair", () => {
97
+ resetThemeMode();
98
+ const el = ScopeSquares({ user: false, project: false, local: false });
99
+ expect(new Set(fills(el))).toEqual(new Set([SCOPE_OFF_FILL.dark]));
100
+ });
101
+ });
102
+
103
+ describe("the unlit fill recedes toward the page it is on", () => {
104
+ /**
105
+ * How far a fill sits from the page, relative to the NEAREST lit segment.
106
+ *
107
+ * The naive property — "unlit is closer to the page than a lit colour" — is
108
+ * too weak, and measurably so: the old #6B7280 satisfied it on a light page
109
+ * (0.766 vs 0.774) while still looking wrong. What made the bar read as three
110
+ * similar blobs was that unlit sat **99% as far** from the page as a lit
111
+ * segment: essentially the same visual weight. Ratio, not order, is the
112
+ * property that captures the defect.
113
+ */
114
+ const pageDistanceRatio = (fill: string, page: string) => {
115
+ const d = (hex: string) =>
116
+ Math.abs(relativeLuminance(hex) - relativeLuminance(page));
117
+ const nearestLit = Math.min(...Object.values(theme.scopes).map(d));
118
+ return d(fill) / nearestLit;
119
+ };
120
+
121
+ /** Unlit must be at most half as far from the page as the nearest lit segment. */
122
+ const MAX_RATIO = 0.5;
123
+
124
+ test("light page: unlit recedes (measured 0.32)", () => {
125
+ expect(
126
+ pageDistanceRatio(SCOPE_OFF_FILL.light, CONTRAST_REFERENCE.light),
127
+ ).toBeLessThan(MAX_RATIO);
128
+ });
129
+
130
+ test("dark page: unlit recedes (measured 0.22)", () => {
131
+ expect(
132
+ pageDistanceRatio(SCOPE_OFF_FILL.dark, CONTRAST_REFERENCE.dark),
133
+ ).toBeLessThan(MAX_RATIO);
134
+ });
135
+
136
+ test("the old mid-grey fails the property on BOTH pages", () => {
137
+ // Pins why the value changed, and guards against anyone reinstating it:
138
+ // 0.99 on cream and 1.10 on near-black — an unlit segment as heavy as a lit
139
+ // one, or heavier.
140
+ expect(
141
+ pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.light),
142
+ ).toBeGreaterThan(MAX_RATIO);
143
+ expect(
144
+ pageDistanceRatio("#6B7280", CONTRAST_REFERENCE.dark),
145
+ ).toBeGreaterThan(MAX_RATIO);
146
+ });
147
+ });
148
+
149
+ describe("selected rows", () => {
150
+ test("unlit segments recede into the selection block, lit take its ink", () => {
151
+ setThemeMode("light");
152
+ const el = ScopeSquares({
153
+ user: true,
154
+ project: false,
155
+ local: true,
156
+ selected: true,
157
+ });
158
+ expect(fills(el)).toEqual([
159
+ theme.selection.fg,
160
+ theme.selection.bg,
161
+ theme.selection.fg,
162
+ ]);
163
+ });
164
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BoxRenderable, TextRenderable } from "@opentui/core";
3
+ import { createTestRenderer } from "@opentui/core/testing";
4
+
5
+ /**
6
+ * Pins the OpenTUI selection behaviour magus relies on after enabling the
7
+ * mouse outside tmux (src/main.tsx): a drag that stays inside one pane selects
8
+ * text from that pane only, because the renderer scopes the selection to the
9
+ * smallest ancestor covering the dragged-over renderables. Crossing the pane
10
+ * divider deliberately escalates the scope to the common ancestor — that is
11
+ * upstream's design, not a bug, and the second test documents it so a future
12
+ * re-pin that changes either half is caught here rather than by a user.
13
+ *
14
+ * The geometry mirrors ScreenLayout: two column boxes side by side under one
15
+ * row box, text rows inside each.
16
+ */
17
+ async function twoPaneRenderer() {
18
+ const { renderer, mockMouse, renderOnce } = await createTestRenderer({
19
+ width: 80,
20
+ height: 10,
21
+ useMouse: true,
22
+ });
23
+
24
+ const row = new BoxRenderable(renderer, {
25
+ id: "row",
26
+ width: 80,
27
+ height: 10,
28
+ flexDirection: "row",
29
+ });
30
+ renderer.root.add(row);
31
+
32
+ const left = new BoxRenderable(renderer, {
33
+ id: "left",
34
+ width: 40,
35
+ height: 10,
36
+ flexDirection: "column",
37
+ });
38
+ const separator = new BoxRenderable(renderer, {
39
+ id: "separator",
40
+ width: 1,
41
+ height: 10,
42
+ });
43
+ const right = new BoxRenderable(renderer, {
44
+ id: "right",
45
+ width: 39,
46
+ height: 10,
47
+ flexDirection: "column",
48
+ });
49
+ row.add(left);
50
+ row.add(separator);
51
+ row.add(right);
52
+
53
+ for (const line of ["alpha one", "alpha two", "alpha three"]) {
54
+ left.add(new TextRenderable(renderer, { content: line }));
55
+ }
56
+ for (const line of ["omega one", "omega two", "omega three"]) {
57
+ right.add(new TextRenderable(renderer, { content: line }));
58
+ }
59
+
60
+ await renderOnce();
61
+ return { renderer, mockMouse };
62
+ }
63
+
64
+ describe("mouse selection scope across panes", () => {
65
+ test("a drag inside the left pane selects left-pane text only", async () => {
66
+ const { renderer, mockMouse } = await twoPaneRenderer();
67
+
68
+ await mockMouse.drag(1, 0, 20, 2);
69
+
70
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
71
+ expect(selected).toContain("alpha");
72
+ expect(selected).not.toContain("omega");
73
+
74
+ renderer.destroy();
75
+ });
76
+
77
+ test("a drag inside the right pane selects right-pane text only", async () => {
78
+ const { renderer, mockMouse } = await twoPaneRenderer();
79
+
80
+ await mockMouse.drag(45, 0, 70, 2);
81
+
82
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
83
+ expect(selected).toContain("omega");
84
+ expect(selected).not.toContain("alpha");
85
+
86
+ renderer.destroy();
87
+ });
88
+
89
+ test("a drag across the divider escalates to both panes — upstream's design", async () => {
90
+ const { renderer, mockMouse } = await twoPaneRenderer();
91
+
92
+ await mockMouse.drag(1, 0, 70, 2);
93
+
94
+ const selected = renderer.getSelection()?.getSelectedText() ?? "";
95
+ expect(selected).toContain("alpha");
96
+ expect(selected).toContain("omega");
97
+
98
+ renderer.destroy();
99
+ });
100
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `services/settings-file.ts` — the one place magus edits a settings.json.
3
+ *
4
+ * Every test here pins a failure mode that has cost, or would cost, a user
5
+ * their configuration:
6
+ *
7
+ * - an unparseable file must be REFUSED, and left byte-for-byte alone;
8
+ * - a symlinked settings.json must be written THROUGH the link, because that
9
+ * is the shape an active profile leaves behind (`.claude/settings.json` →
10
+ * `.claude/_profiles/<name>/settings.json`). Remove-then-create would
11
+ * replace the link with a plain file and detach the project from its
12
+ * profile, which nothing downstream would notice.
13
+ */
14
+
15
+ import { afterEach, describe, expect, test } from "bun:test";
16
+ import {
17
+ lstatSync,
18
+ mkdtempSync,
19
+ readFileSync,
20
+ rmSync,
21
+ symlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import {
27
+ readSettingsFile,
28
+ updateSettingsFile,
29
+ } from "../services/settings-file.js";
30
+
31
+ const dirs: string[] = [];
32
+
33
+ afterEach(() => {
34
+ for (const dir of dirs.splice(0))
35
+ rmSync(dir, { recursive: true, force: true });
36
+ });
37
+
38
+ function tempDir(): string {
39
+ const dir = mkdtempSync(path.join(os.tmpdir(), "magus-settings-file-"));
40
+ dirs.push(dir);
41
+ return dir;
42
+ }
43
+
44
+ describe("readSettingsFile", () => {
45
+ test("S-1: a missing file reads as an empty object", async () => {
46
+ const missing = path.join(tempDir(), "nested", "settings.json");
47
+ expect(await readSettingsFile(missing)).toEqual({});
48
+ });
49
+
50
+ test("S-1b: an empty file reads as an empty object", async () => {
51
+ const file = path.join(tempDir(), "settings.json");
52
+ writeFileSync(file, " \n");
53
+ expect(await readSettingsFile(file)).toEqual({});
54
+ });
55
+
56
+ test("S-2: an unparseable file throws, naming the path and the refusal", async () => {
57
+ const file = path.join(tempDir(), "settings.json");
58
+ writeFileSync(file, "{ not json");
59
+
60
+ await expect(readSettingsFile(file)).rejects.toThrow(/not valid JSON/);
61
+ await expect(readSettingsFile(file)).rejects.toThrow(
62
+ /refusing to overwrite/,
63
+ );
64
+ await expect(readSettingsFile(file)).rejects.toThrow(file);
65
+ });
66
+ });
67
+
68
+ describe("updateSettingsFile", () => {
69
+ test("S-3: a missing file starts from {} and is created", async () => {
70
+ const file = path.join(tempDir(), "nested", "settings.json");
71
+
72
+ await updateSettingsFile(file, (settings) => {
73
+ settings.outputStyle = "composed";
74
+ });
75
+
76
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
77
+ outputStyle: "composed",
78
+ });
79
+ });
80
+
81
+ test("S-4: a valid file round-trips and unrelated keys survive", async () => {
82
+ const file = path.join(tempDir(), "settings.json");
83
+ writeFileSync(
84
+ file,
85
+ `${JSON.stringify(
86
+ {
87
+ enabledPlugins: { "dev@magus": true },
88
+ someOtherKey: 42,
89
+ outputStyle: "old",
90
+ },
91
+ null,
92
+ 2,
93
+ )}\n`,
94
+ );
95
+
96
+ await updateSettingsFile(file, (settings) => {
97
+ settings.outputStyle = "new";
98
+ });
99
+
100
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
101
+ enabledPlugins: { "dev@magus": true },
102
+ someOtherKey: 42,
103
+ outputStyle: "new",
104
+ });
105
+ });
106
+
107
+ test("S-4b: the mutator may return a replacement object instead of mutating", async () => {
108
+ const file = path.join(tempDir(), "settings.json");
109
+ writeFileSync(file, `${JSON.stringify({ a: 1 }, null, 2)}\n`);
110
+
111
+ await updateSettingsFile(file, (settings) => ({ ...settings, b: 2 }));
112
+
113
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ a: 1, b: 2 });
114
+ });
115
+
116
+ test("S-4c: deleting a key removes it rather than serializing undefined", async () => {
117
+ const file = path.join(tempDir(), "settings.json");
118
+ writeFileSync(file, `${JSON.stringify({ a: 1, outputStyle: "x" })}\n`);
119
+
120
+ await updateSettingsFile(file, (settings) => {
121
+ // biome-ignore lint/performance/noDelete: removal is the intent
122
+ delete settings.outputStyle;
123
+ });
124
+
125
+ const raw = readFileSync(file, "utf8");
126
+ expect(raw).not.toContain("outputStyle");
127
+ expect(JSON.parse(raw)).toEqual({ a: 1 });
128
+ });
129
+
130
+ test("S-4d: the write is 2-space JSON with a trailing newline", async () => {
131
+ const file = path.join(tempDir(), "settings.json");
132
+
133
+ await updateSettingsFile(file, () => ({ a: { b: 1 } }));
134
+
135
+ expect(readFileSync(file, "utf8")).toBe(
136
+ '{\n "a": {\n "b": 1\n }\n}\n',
137
+ );
138
+ });
139
+
140
+ test("S-5: an unparseable file throws and the bytes on disk are UNCHANGED", async () => {
141
+ const file = path.join(tempDir(), "settings.json");
142
+ const original = '{ "half": tru\n// a human mid-edit\n';
143
+ writeFileSync(file, original);
144
+ let mutatorRan = false;
145
+
146
+ await expect(
147
+ updateSettingsFile(file, (settings) => {
148
+ mutatorRan = true;
149
+ settings.outputStyle = "composed";
150
+ }),
151
+ ).rejects.toThrow(/not valid JSON, refusing to overwrite it/);
152
+
153
+ expect(mutatorRan).toBe(false);
154
+ expect(readFileSync(file, "utf8")).toBe(original);
155
+ });
156
+
157
+ test("S-6: a symlinked settings.json is written THROUGH the link — the profile case", async () => {
158
+ const dir = tempDir();
159
+ const target = path.join(dir, "profile-settings.json");
160
+ const link = path.join(dir, "settings.json");
161
+ writeFileSync(
162
+ target,
163
+ `${JSON.stringify({ enabledPlugins: { "dev@magus": true } }, null, 2)}\n`,
164
+ );
165
+ symlinkSync(target, link);
166
+
167
+ await updateSettingsFile(link, (settings) => {
168
+ settings.outputStyle = "composed";
169
+ });
170
+
171
+ // The profile's real file received the write …
172
+ expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
173
+ enabledPlugins: { "dev@magus": true },
174
+ outputStyle: "composed",
175
+ });
176
+ // … and the link is still a link, not a plain file that replaced it.
177
+ expect(lstatSync(link).isSymbolicLink()).toBe(true);
178
+ });
179
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `runShellScript` has exactly one legitimate caller, and this pins it.
3
+ *
4
+ * The whole of item 3 is the split between two ways of running a child process:
5
+ * argv (`runCommand`), which no shell ever sees, and a script (`runShellScript`),
6
+ * which `/bin/sh` parses. Every installer command belongs to the first, because
7
+ * it is assembled from package names that arrive in `.claude/profiles.json` and
8
+ * plugin `requires.bin` — hand-authored, committed by teammates, fetched from
9
+ * marketplaces, validated by nothing. The second exists only for the two
10
+ * `TOOLCHAIN_BOOTSTRAP` values, which are constants in this repo copied from
11
+ * bun.sh and brew.sh and which genuinely contain a pipe and a command
12
+ * substitution.
13
+ *
14
+ * Nothing in the type system distinguishes them — both are strings — so after
15
+ * the commit lands, the design decision is enforced by this test or by nothing.
16
+ * A branded `ShellScript` type would enforce it at compile time and was
17
+ * considered; for one map, two entries and one call site it is more machinery
18
+ * than the rule is worth, and unlike a brand this is greppable.
19
+ *
20
+ * Source-scanning precedent in this suite: `uppercase-keybindings.test.ts`.
21
+ */
22
+
23
+ import { describe, expect, test } from "bun:test";
24
+ import path from "node:path";
25
+ import fs from "fs-extra";
26
+
27
+ const SRC_DIR = path.join(import.meta.dir, "..");
28
+
29
+ /** Files permitted to call `runShellScript`, relative to `src/`. */
30
+ const ALLOWED = ["cli/install.ts"];
31
+
32
+ /** The definition itself, plus this test, are not callers. */
33
+ const NOT_CALLERS = ["utils/run.ts", "__tests__/shell-script-callers.test.ts"];
34
+
35
+ async function sourceFiles(dir: string): Promise<string[]> {
36
+ const out: string[] = [];
37
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) out.push(...(await sourceFiles(full)));
40
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const isComment = (line: string) => {
46
+ const t = line.trim();
47
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
48
+ };
49
+
50
+ async function callersOf(pattern: RegExp): Promise<string[]> {
51
+ const found = new Set<string>();
52
+ for (const file of await sourceFiles(SRC_DIR)) {
53
+ const rel = path.relative(SRC_DIR, file);
54
+ if (NOT_CALLERS.includes(rel)) continue;
55
+ const text = await fs.readFile(file, "utf8");
56
+ for (const line of text.split("\n")) {
57
+ if (isComment(line)) continue;
58
+ if (pattern.test(line)) found.add(rel);
59
+ }
60
+ }
61
+ return [...found].sort();
62
+ }
63
+
64
+ describe("only the toolchain bootstrap goes through a shell", () => {
65
+ test("runShellScript is called from exactly the allowed files", async () => {
66
+ expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
67
+ });
68
+
69
+ test("nothing spawns with a shell outside utils/run.ts", async () => {
70
+ // Catches someone reaching for `spawn`/`exec` directly rather than
71
+ // through the seam — a second shell path that never names
72
+ // `runShellScript`.
73
+ //
74
+ // Both spellings: `shell: true` and a named shell (`shell: "/bin/bash"`).
75
+ // The CLI Tools screen used the second for its version probe (`which -a`
76
+ // pipelines, a string-form `exec` of each check command); that probe now
77
+ // lives in services/tool-probe.ts and runs argv only. `shell: false` and
78
+ // the alias writer's `shell: ShellKind` values are not spawn options and
79
+ // do not match.
80
+ expect(await callersOf(/shell:\s*(true|["'`])/)).toEqual([]);
81
+ });
82
+
83
+ test("the scan can actually fail", async () => {
84
+ // Negative control. The two assertions above pass trivially if the walk
85
+ // finds nothing or the pattern never matches, and a check that cannot
86
+ // fail is not evidence. `runCommand(` is known to be called from several
87
+ // files, so a working scan must find them.
88
+ const runCommandCallers = await callersOf(/\brunCommand\(/);
89
+ expect(runCommandCallers.length).toBeGreaterThan(0);
90
+ expect(runCommandCallers).toContain("cli/install.ts");
91
+ });
92
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ resolveSkillFilePath,
5
+ selectSkillFiles,
6
+ } from "../services/skills-manager.js";
7
+
8
+ /**
9
+ * Installing a skill used to write exactly one file, SKILL.md, and drop
10
+ * everything beside it. Measured against the real repos: audit-website lost
11
+ * `references/OUTPUT-FORMAT.md` while its SKILL.md still linked to it,
12
+ * systematic-debugging lost `find-polluter.sh` and 9 more, and
13
+ * remotion-best-practices lost 136 files. The installed skill looked fine and
14
+ * had dangling links.
15
+ *
16
+ * These cover the two pieces of that fix that are pure: choosing which files
17
+ * belong to a skill, and refusing to write outside its directory.
18
+ */
19
+
20
+ const blob = (p: string) => ({ path: p, type: "blob" });
21
+ const tree = (p: string) => ({ path: p, type: "tree" });
22
+
23
+ describe("selectSkillFiles", () => {
24
+ test("returns paths relative to the skill directory", () => {
25
+ expect(
26
+ selectSkillFiles(
27
+ [
28
+ blob("skills/audit-website/SKILL.md"),
29
+ blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
30
+ blob("skills/audit-website/assets/icon-small.svg"),
31
+ ],
32
+ "skills/audit-website",
33
+ ),
34
+ ).toEqual([
35
+ "SKILL.md",
36
+ "references/OUTPUT-FORMAT.md",
37
+ "assets/icon-small.svg",
38
+ ]);
39
+ });
40
+
41
+ test("REGRESSION: a sibling with a shared name prefix is excluded", () => {
42
+ // Without the trailing slash on the prefix, "skills/audit" also matches
43
+ // "skills/audit-website/..." and dumps a second skill's files into the
44
+ // first one's directory.
45
+ expect(
46
+ selectSkillFiles(
47
+ [
48
+ blob("skills/audit/SKILL.md"),
49
+ blob("skills/audit-website/SKILL.md"),
50
+ blob("skills/audit-website/references/OUTPUT-FORMAT.md"),
51
+ ],
52
+ "skills/audit",
53
+ ),
54
+ ).toEqual(["SKILL.md"]);
55
+ });
56
+
57
+ test("tree entries are skipped — only blobs are downloadable", () => {
58
+ expect(
59
+ selectSkillFiles(
60
+ [
61
+ tree("skills/x/references"),
62
+ blob("skills/x/SKILL.md"),
63
+ blob("skills/x/references/a.md"),
64
+ ],
65
+ "skills/x",
66
+ ),
67
+ ).toEqual(["SKILL.md", "references/a.md"]);
68
+ });
69
+
70
+ test("files outside the skill directory are excluded", () => {
71
+ expect(
72
+ selectSkillFiles(
73
+ [blob("README.md"), blob("other/SKILL.md"), blob("skills/x/SKILL.md")],
74
+ "skills/x",
75
+ ),
76
+ ).toEqual(["SKILL.md"]);
77
+ });
78
+
79
+ test("a trailing slash on the skill path does not break matching", () => {
80
+ expect(selectSkillFiles([blob("skills/x/SKILL.md")], "skills/x/")).toEqual([
81
+ "SKILL.md",
82
+ ]);
83
+ });
84
+
85
+ test("a skill directory with no files yields nothing", () => {
86
+ expect(
87
+ selectSkillFiles([blob("skills/other/SKILL.md")], "skills/x"),
88
+ ).toEqual([]);
89
+ });
90
+ });
91
+
92
+ describe("resolveSkillFilePath", () => {
93
+ const dir = path.join(path.sep, "tmp", "skills", "demo");
94
+
95
+ test("resolves a plain relative path inside the install directory", () => {
96
+ expect(resolveSkillFilePath(dir, "SKILL.md")).toBe(
97
+ path.join(dir, "SKILL.md"),
98
+ );
99
+ });
100
+
101
+ test("resolves a nested path", () => {
102
+ expect(resolveSkillFilePath(dir, "references/OUTPUT-FORMAT.md")).toBe(
103
+ path.join(dir, "references", "OUTPUT-FORMAT.md"),
104
+ );
105
+ });
106
+
107
+ test("SECURITY: rejects paths escaping via ..", () => {
108
+ // These paths come from a remote repository and are untrusted input to a
109
+ // filesystem write.
110
+ expect(resolveSkillFilePath(dir, "../evil.md")).toBeNull();
111
+ expect(resolveSkillFilePath(dir, "../../.bashrc")).toBeNull();
112
+ expect(resolveSkillFilePath(dir, "refs/../../../evil.md")).toBeNull();
113
+ });
114
+
115
+ test("SECURITY: rejects absolute paths", () => {
116
+ expect(
117
+ resolveSkillFilePath(dir, path.join(path.sep, "etc", "passwd")),
118
+ ).toBeNull();
119
+ });
120
+
121
+ test("rejects an empty path", () => {
122
+ expect(resolveSkillFilePath(dir, "")).toBeNull();
123
+ });
124
+
125
+ test("a .. that stays inside the directory is allowed", () => {
126
+ expect(resolveSkillFilePath(dir, "refs/../SKILL.md")).toBe(
127
+ path.join(dir, "SKILL.md"),
128
+ );
129
+ });
130
+
131
+ test("REGRESSION: a sibling directory sharing the name prefix is rejected", () => {
132
+ // path.startsWith without a separator would accept /tmp/skills/demo-evil
133
+ // as being inside /tmp/skills/demo.
134
+ expect(resolveSkillFilePath(dir, "../demo-evil/x.md")).toBeNull();
135
+ });
136
+ });