magus-cli 0.0.0-placeholder → 7.0.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 (331) 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 +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -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 +445 -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 +174 -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 +335 -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__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,315 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { join } from "node:path";
3
+ import fs from "fs-extra";
4
+ import { DEFAULT_PRESET, findPreset } from "../data/models-presets.js";
5
+ import type { ModelsStatus } from "../services/models-core.js";
6
+ import { appReducer, initialState } from "../ui/state/reducer.js";
7
+ import type { AppAction, AppState, ModelsSnapshot } from "../ui/state/types.js";
8
+ import { asyncValue } from "../ui/state/types.js";
9
+
10
+ function run(actions: AppAction[], from: AppState = initialState): AppState {
11
+ return actions.reduce(appReducer, from);
12
+ }
13
+
14
+ function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
15
+ return { state: "off", preset: null, drift: [], warnings: [], ...over };
16
+ }
17
+
18
+ function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
19
+ return {
20
+ status: status(),
21
+ config: null,
22
+ path: "/x/.claude/models.json",
23
+ ...over,
24
+ };
25
+ }
26
+
27
+ /** Drop `//` line comments and block comments, so a source guard matches code only. */
28
+ function stripComments(source: string): string {
29
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
30
+ }
31
+
32
+ /** Navigating away and back is what unmounts the screen. */
33
+ const roundTrip: AppAction[] = [
34
+ { type: "NAVIGATE", route: { screen: "plugins" } },
35
+ { type: "NAVIGATE", route: { screen: "models" } },
36
+ ];
37
+
38
+ describe("models screen initial state", () => {
39
+ test("starts idle, unfiltered, and with no message", () => {
40
+ expect(initialState.models.data.status).toBe("idle");
41
+ expect(initialState.models.searchQuery).toBe("");
42
+ expect(initialState.models.status).toBeNull();
43
+ expect(initialState.models.isApplying).toBe(false);
44
+ // The mate slots are not drawn until something says they can be served. Starting
45
+ // true would put three rows on screen before anything had been asked.
46
+ expect(initialState.models.matesAvailable).toBe(false);
47
+ });
48
+
49
+ test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
50
+ const state = run([
51
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
52
+ ...roundTrip,
53
+ ]);
54
+ expect(state.currentRoute.screen).toBe("models");
55
+ expect(state.models.data.status).toBe("success");
56
+ });
57
+ });
58
+
59
+ describe("models search state", () => {
60
+ test("appending builds up the query one character at a time", () => {
61
+ // The regression this guards: computing `searchQuery + char` in the screen
62
+ // reads a value captured at render time, so keystrokes arriving faster
63
+ // than React re-renders all start from the same stale string.
64
+ const state = run([
65
+ { type: "MODELS_SEARCH_APPEND", char: "o" },
66
+ { type: "MODELS_SEARCH_APPEND", char: "p" },
67
+ { type: "MODELS_SEARCH_APPEND", char: "u" },
68
+ { type: "MODELS_SEARCH_APPEND", char: "s" },
69
+ ]);
70
+ expect(state.models.searchQuery).toBe("opus");
71
+ });
72
+
73
+ test("appending resets the cursor so it cannot sit past the filtered list", () => {
74
+ const state = run([
75
+ { type: "MODELS_SELECT", index: 4 },
76
+ { type: "MODELS_SEARCH_APPEND", char: "h" },
77
+ ]);
78
+ expect(state.models.selectedIndex).toBe(0);
79
+ });
80
+
81
+ test("backspace removes one character at a time", () => {
82
+ const typed = run([
83
+ { type: "MODELS_SEARCH_APPEND", char: "a" },
84
+ { type: "MODELS_SEARCH_APPEND", char: "b" },
85
+ { type: "MODELS_SEARCH_APPEND", char: "c" },
86
+ ]);
87
+ expect(
88
+ run([{ type: "MODELS_SEARCH_BACKSPACE" }], typed).models.searchQuery,
89
+ ).toBe("ab");
90
+ });
91
+
92
+ test("backspace on an empty query is harmless", () => {
93
+ expect(run([{ type: "MODELS_SEARCH_BACKSPACE" }]).models.searchQuery).toBe(
94
+ "",
95
+ );
96
+ });
97
+
98
+ test("clearing the filter is one action, and it leaves the data alone", () => {
99
+ const filtered = run([
100
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
101
+ { type: "MODELS_SEARCH_APPEND", char: "z" },
102
+ ]);
103
+ const cleared = run([{ type: "MODELS_SET_SEARCH", query: "" }], filtered);
104
+ expect(cleared.models.searchQuery).toBe("");
105
+ expect(cleared.models.data.status).toBe("success");
106
+ });
107
+ });
108
+
109
+ describe("models data state", () => {
110
+ test("a reload keeps the last snapshot on screen", () => {
111
+ // Reading the routing scans session transcripts to resolve the live model
112
+ // id behind each alias, so a reload is not instant. Without `previous` the
113
+ // list blanks to "Reading…" every time — including on every tab switch,
114
+ // because the router unmounts the screen and the effect refetches.
115
+ const loaded = run([
116
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot({ path: "/a" }) },
117
+ { type: "MODELS_DATA_LOADING" },
118
+ ]);
119
+ expect(loaded.models.data.status).toBe("loading");
120
+ expect(asyncValue(loaded.models.data)?.path).toBe("/a");
121
+ });
122
+
123
+ test("a FIRST load has nothing to carry, and says so", () => {
124
+ const loading = run([{ type: "MODELS_DATA_LOADING" }]);
125
+ expect(asyncValue(loading.models.data)).toBeUndefined();
126
+ });
127
+
128
+ test("success stores the whole snapshot, config included", () => {
129
+ const config = findPreset(DEFAULT_PRESET) ?? null;
130
+ const state = run([
131
+ {
132
+ type: "MODELS_DATA_SUCCESS",
133
+ snapshot: snapshot({
134
+ status: status({ state: "on", preset: DEFAULT_PRESET }),
135
+ config,
136
+ }),
137
+ },
138
+ ]);
139
+ const value = asyncValue(state.models.data);
140
+ expect(value?.status.state).toBe("on");
141
+ expect(value?.config?.preset).toBe(DEFAULT_PRESET);
142
+ });
143
+
144
+ test("an error replaces the data rather than hiding behind stale rows", () => {
145
+ const state = run([
146
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
147
+ { type: "MODELS_DATA_ERROR", error: new Error("unreadable") },
148
+ ]);
149
+ expect(state.models.data.status).toBe("error");
150
+ expect(asyncValue(state.models.data)).toBeUndefined();
151
+ });
152
+ });
153
+
154
+ describe("models status line", () => {
155
+ test("survives the screen unmounting, because it lives in app state", () => {
156
+ // `Router` swaps the component type on a tab change, so a message held in
157
+ // the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
158
+ // the only record of what the last apply did.
159
+ const messaged = run([
160
+ {
161
+ type: "MODELS_STATUS_SET",
162
+ status: { text: "Routed opus-lead", tone: "success" },
163
+ },
164
+ ]);
165
+ expect(run(roundTrip, messaged).models.status?.text).toBe(
166
+ "Routed opus-lead",
167
+ );
168
+ });
169
+
170
+ test("is replaced by the next message, not appended to", () => {
171
+ const state = run([
172
+ { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
173
+ { type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
174
+ ]);
175
+ expect(state.models.status).toEqual({ text: "two", tone: "error" });
176
+ });
177
+
178
+ test("clearing removes it", () => {
179
+ const state = run([
180
+ { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
181
+ { type: "MODELS_STATUS_CLEAR" },
182
+ ]);
183
+ expect(state.models.status).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("apply-in-flight state", () => {
188
+ test("survives the screen unmounting", () => {
189
+ // An apply rewrites the profile, re-materializes it and registers a hook.
190
+ // Held locally, the flag died on a tab switch and a returning user could
191
+ // launch a second write on top of the first.
192
+ const applying = run([{ type: "MODELS_APPLY_START" }]);
193
+ expect(applying.models.isApplying).toBe(true);
194
+ expect(run(roundTrip, applying).models.isApplying).toBe(true);
195
+ });
196
+
197
+ test("clears when the write finishes", () => {
198
+ expect(
199
+ run([{ type: "MODELS_APPLY_START" }, { type: "MODELS_APPLY_END" }]).models
200
+ .isApplying,
201
+ ).toBe(false);
202
+ });
203
+ });
204
+
205
+ describe("the mate slots' availability", () => {
206
+ test("survives the screen unmounting, so the lookup happens once a session", () => {
207
+ // Unlike the two file reads this screen does on mount, this answer costs a
208
+ // marketplace resolution with network timeouts behind it. Held locally it would die
209
+ // on every tab switch and be paid for again on every return — for something that
210
+ // cannot change while the TUI is open.
211
+ const available = run([
212
+ { type: "MODELS_MATES_AVAILABLE", available: true },
213
+ ]);
214
+ expect(available.models.matesAvailable).toBe(true);
215
+ expect(run(roundTrip, available).models.matesAvailable).toBe(true);
216
+ });
217
+
218
+ test("a false answer is recorded, not treated as no answer", () => {
219
+ // "Not installed" and "never asked" render the same, and that is deliberate — but
220
+ // the reducer still has to hold the difference, or a later dispatch could not undo it.
221
+ const state = run([
222
+ { type: "MODELS_MATES_AVAILABLE", available: true },
223
+ { type: "MODELS_MATES_AVAILABLE", available: false },
224
+ ]);
225
+ expect(state.models.matesAvailable).toBe(false);
226
+ });
227
+
228
+ test("it touches no other part of the models state", () => {
229
+ const before = run([
230
+ { type: "MODELS_SELECT", index: 2 },
231
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
232
+ ]);
233
+ const after = run(
234
+ [{ type: "MODELS_MATES_AVAILABLE", available: true }],
235
+ before,
236
+ );
237
+ expect(after.models.selectedIndex).toBe(2);
238
+ expect(after.models.data).toBe(before.models.data);
239
+ expect(after.styles).toBe(before.styles);
240
+ });
241
+ });
242
+
243
+ describe("selection state", () => {
244
+ test("select records the index verbatim", () => {
245
+ expect(
246
+ run([{ type: "MODELS_SELECT", index: 3 }]).models.selectedIndex,
247
+ ).toBe(3);
248
+ });
249
+
250
+ test("models actions touch no other screen's state", () => {
251
+ // One shared reducer, so a mistyped spread is a real hazard: `...state`
252
+ // rather than `...state.models` would wipe every other screen.
253
+ const before = initialState;
254
+ const after = run([
255
+ { type: "MODELS_SELECT", index: 2 },
256
+ { type: "MODELS_APPLY_START" },
257
+ { type: "MODELS_SEARCH_APPEND", char: "x" },
258
+ ]);
259
+ expect(after.styles).toBe(before.styles);
260
+ expect(after.plugins).toBe(before.plugins);
261
+ expect(after.skills).toBe(before.skills);
262
+ });
263
+ });
264
+
265
+ // ─── Keybindings ──────────────────────────────────────────────────────────────
266
+
267
+ const SCREEN = join(import.meta.dir, "..", "ui", "screens", "ModelsScreen.tsx");
268
+
269
+ describe("ModelsScreen keybindings", () => {
270
+ test("a and Enter both apply, and c clears", async () => {
271
+ const src = await fs.readFile(SCREEN, "utf8");
272
+ expect(src).toContain('event.name === "a"');
273
+ expect(src).toContain('event.name === "c"');
274
+ expect(src).toContain('event.name === "r"');
275
+ expect(src).toContain('event.name === "/"');
276
+ // Enter is the same action as `a`. There is nothing to "open" here, so
277
+ // binding the most obvious key to nothing would be the wrong lesson.
278
+ const enter = src.indexOf('event.name === "return"');
279
+ expect(enter).toBeGreaterThan(-1);
280
+ expect(src.slice(enter, enter + 400)).toContain("handleApply()");
281
+ });
282
+
283
+ test("digits are treated as navigation, so a message survives a tab round trip", async () => {
284
+ // Every digit now navigates, 0 included. Clearing the status line on the
285
+ // way OUT of the tab loses the message the state was changed to preserve.
286
+ const src = await fs.readFile(SCREEN, "utf8");
287
+ expect(src).toContain("/^[0-9]$/.test(event.name");
288
+ });
289
+
290
+ test("no screen still treats 0 as a non-navigation key", async () => {
291
+ // The Styles screen shipped `/^[1-9]$/` before Models existed on key 0.
292
+ const styles = await fs.readFile(
293
+ join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
294
+ "utf8",
295
+ );
296
+ expect(styles).not.toContain("/^[1-9]$/");
297
+ });
298
+
299
+ test("screen state is held in the reducer, never in useState", async () => {
300
+ // The rule the two "survives unmounting" tests above enforce at runtime,
301
+ // pinned at the source so a new piece of state cannot quietly opt out.
302
+ //
303
+ // Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
304
+ // is about CODE, and this screen documents why each piece of its state lives in the
305
+ // reducer — naming the hook it is deliberately not using is the clearest way to say
306
+ // that, and a guard reading the prose would forbid the explanation along with the
307
+ // thing explained.
308
+ const src = stripComments(await fs.readFile(SCREEN, "utf8"));
309
+ expect(src).not.toContain("useState");
310
+ // Negative control: stripping must not have eaten the code being guarded. A guard
311
+ // over an empty string passes forever.
312
+ expect(src).toContain("useEffect");
313
+ expect(src).toContain("useCallback");
314
+ });
315
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * An orphaned plugin that merely changed marketplace is not deprecated.
3
+ *
4
+ * Splitting `magus` into `magus` + `magus-marketing` left `video-editing@magus`
5
+ * and `nanobanana@magus` installed under a namespace that no longer lists them. They rendered as bare
6
+ * "deprecated", whose only action is deletion — which drops a plugin that is
7
+ * still published, just under a different id.
8
+ */
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import type { LocalMarketplace } from "../services/local-marketplace.js";
12
+
13
+ /**
14
+ * Mirrors `findPluginInOtherMarketplace` in plugin-manager.ts, which is module
15
+ * -private. Kept in lockstep with it; the behaviour, not the binding, is what
16
+ * these tests pin.
17
+ */
18
+ function findPluginInOtherMarketplace(
19
+ pluginName: string,
20
+ currentMarketplace: string,
21
+ localMarketplaces: Map<string, LocalMarketplace>,
22
+ ): string | undefined {
23
+ for (const [name, mp] of localMarketplaces) {
24
+ if (name === currentMarketplace) continue;
25
+ if (mp.plugins.some((p) => p.name === pluginName)) return name;
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ const mp = (name: string, plugins: string[]): [string, LocalMarketplace] => [
31
+ name,
32
+ {
33
+ name,
34
+ description: "",
35
+ plugins: plugins.map((p) => ({
36
+ name: p,
37
+ version: "1.0.0",
38
+ description: "",
39
+ })),
40
+ },
41
+ ];
42
+
43
+ describe("moved-marketplace detection", () => {
44
+ test("finds a plugin that moved to a sibling marketplace", () => {
45
+ const marketplaces = new Map([
46
+ mp("magus", ["dev", "terminal"]),
47
+ mp("magus-marketing", ["image-generate", "video-editing"]),
48
+ ]);
49
+
50
+ expect(
51
+ findPluginInOtherMarketplace("video-editing", "magus", marketplaces),
52
+ ).toBe("magus-marketing");
53
+ });
54
+
55
+ test("a genuinely retired plugin reports no destination", () => {
56
+ // `conductor` was removed at magus v8.0.0 and republished nowhere.
57
+ const marketplaces = new Map([
58
+ mp("magus", ["dev"]),
59
+ mp("magus-marketing", ["video-editing"]),
60
+ ]);
61
+
62
+ expect(
63
+ findPluginInOtherMarketplace("conductor", "magus", marketplaces),
64
+ ).toBeUndefined();
65
+ });
66
+
67
+ test("does not propose the marketplace it is already installed from", () => {
68
+ const marketplaces = new Map([mp("magus", ["dev"])]);
69
+ expect(findPluginInOtherMarketplace("dev", "magus", marketplaces)).toBe(
70
+ undefined,
71
+ );
72
+ });
73
+
74
+ test("a renamed plugin is not matched — the name is the identity", () => {
75
+ // nanobanana became image-generate. Names differ, so this correctly
76
+ // reports no destination rather than guessing at a rename.
77
+ const marketplaces = new Map([
78
+ mp("magus", ["dev"]),
79
+ mp("magus-marketing", ["image-generate"]),
80
+ ]);
81
+
82
+ expect(
83
+ findPluginInOtherMarketplace("nanobanana", "magus", marketplaces),
84
+ ).toBeUndefined();
85
+ });
86
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { openInDefaultApp, openerFor } from "../utils/open-file.js";
3
+
4
+ describe("openerFor", () => {
5
+ test("uses the macOS opener on darwin", () => {
6
+ expect(openerFor("darwin")).toEqual({ command: "open", args: [] });
7
+ });
8
+
9
+ test("uses xdg-open on linux", () => {
10
+ // magus ships a linux-x64 binary, and the pre-existing `open "${url}"`
11
+ // call elsewhere in the UI silently does nothing there.
12
+ expect(openerFor("linux")).toEqual({ command: "xdg-open", args: [] });
13
+ });
14
+
15
+ test("passes an empty window title to cmd start on windows", () => {
16
+ // `start` treats its first quoted argument as the window title, so
17
+ // without the empty string a quoted path is consumed as one.
18
+ expect(openerFor("win32")).toEqual({
19
+ command: "cmd",
20
+ args: ["/c", "start", ""],
21
+ });
22
+ });
23
+
24
+ test("falls back to xdg-open on anything else", () => {
25
+ expect(openerFor("freebsd").command).toBe("xdg-open");
26
+ });
27
+ });
28
+
29
+ describe("openInDefaultApp", () => {
30
+ const missingOpener = {
31
+ command: "magus-test-opener-that-cannot-exist",
32
+ args: [],
33
+ };
34
+
35
+ test("reports a missing opener rather than failing silently", async () => {
36
+ // An injected command that cannot exist, NOT a real platform's opener.
37
+ // The first version assumed `xdg-open` was absent — true on a Mac dev
38
+ // box, false on the Ubuntu CI runner, where it both failed the
39
+ // assertion and spawned a real opener on the build machine.
40
+ await expect(
41
+ openInDefaultApp("/tmp/whatever.md", "linux", missingOpener),
42
+ ).rejects.toThrow(/not on PATH/);
43
+ });
44
+
45
+ test("settles promptly instead of hanging", async () => {
46
+ // The promise must settle when the child is spawned, not when the viewer
47
+ // is closed — waiting for exit would freeze the TUI for as long as the
48
+ // user's editor stayed open. Asserted on the error path so the suite
49
+ // never launches a real application on the developer's desktop.
50
+ const settled = await Promise.race([
51
+ openInDefaultApp("/tmp/whatever.md", "linux", missingOpener).then(
52
+ () => "resolved",
53
+ () => "rejected",
54
+ ),
55
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), 2000)),
56
+ ]);
57
+ expect(settled).not.toBe("timeout");
58
+ });
59
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Change B: our own marketplaces (magus, magus-marketing) are always listed in
3
+ * the plugins TUI — even when unregistered — and sorted to the top.
4
+ *
5
+ * `magus-marketing` is deliberately NOT `featured`, so before this change it was
6
+ * invisible until manually registered. The `owned` flag both (a) qualifies it
7
+ * for the plugin-manager "always include" gate and (b) sorts it first.
8
+ */
9
+ import { describe, expect, it } from "bun:test";
10
+ import {
11
+ defaultMarketplaces,
12
+ getAllMarketplaces,
13
+ } from "../data/marketplaces.js";
14
+ import type { LocalMarketplace } from "../services/local-marketplace.js";
15
+ import type { Marketplace } from "../types/index.js";
16
+ import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
17
+
18
+ describe("owned marketplaces", () => {
19
+ it("marks magus and magus-marketing as owned, and leaves others untouched", () => {
20
+ const magus = defaultMarketplaces.find((m) => m.name === "magus");
21
+ const marketing = defaultMarketplaces.find(
22
+ (m) => m.name === "magus-marketing",
23
+ );
24
+ expect(magus?.owned).toBe(true);
25
+ expect(marketing?.owned).toBe(true);
26
+ expect(
27
+ defaultMarketplaces.find((m) => m.name === "claude-plugins-official")
28
+ ?.owned,
29
+ ).toBeUndefined();
30
+ });
31
+
32
+ it("qualifies magus-marketing for the always-show gate without being featured", () => {
33
+ const marketing = defaultMarketplaces.find(
34
+ (m) => m.name === "magus-marketing",
35
+ );
36
+ expect(marketing).toBeDefined();
37
+ // The plugin-manager gate: configured || official || featured || owned.
38
+ const alwaysShown = !!(
39
+ marketing?.official ||
40
+ marketing?.featured ||
41
+ marketing?.owned
42
+ );
43
+ expect(alwaysShown).toBe(true);
44
+ expect(marketing?.featured).toBeUndefined(); // and NOT via featured
45
+ });
46
+
47
+ it("sorts owned first and deprecated last, regardless of input order", () => {
48
+ const mk = (name: string, extra: Partial<Marketplace>): Marketplace => ({
49
+ name,
50
+ displayName: name,
51
+ source: { source: "github", repo: `x/${name}` },
52
+ description: "",
53
+ ...extra,
54
+ });
55
+ // Deliberately "wrong" order: official, deprecated, owned.
56
+ const marketplaces: Marketplace[] = [
57
+ mk("official-one", { official: true }),
58
+ mk("claude-code-plugins", { deprecated: true }),
59
+ mk("mine", { owned: true }),
60
+ ];
61
+
62
+ const items = buildPluginBrowserItems({
63
+ marketplaces,
64
+ plugins: [],
65
+ collapsedMarketplaces: new Set(),
66
+ });
67
+
68
+ const order: string[] = [];
69
+ for (const i of items)
70
+ if (i.kind === "category") order.push(i.marketplace.name);
71
+
72
+ expect(order[0]).toBe("mine"); // owned first
73
+ expect(order[order.length - 1]).toBe("claude-code-plugins"); // deprecated last
74
+ });
75
+
76
+ it("preserves owned when getAllMarketplaces rebuilds a locally-cached marketplace", () => {
77
+ // The local-cache path reconstructs the Marketplace object; owned must
78
+ // survive so already-cloned magus still sorts first / always shows.
79
+ const local = new Map<string, LocalMarketplace>([
80
+ [
81
+ "magus",
82
+ {
83
+ name: "Magus",
84
+ description: "",
85
+ gitRepo: "MadAppGang/magus",
86
+ plugins: [],
87
+ },
88
+ ],
89
+ ]);
90
+ const magus = getAllMarketplaces(local).find((m) => m.name === "magus");
91
+ expect(magus?.owned).toBe(true);
92
+ });
93
+ });
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ checkoutDirName,
5
+ checkoutPath,
6
+ ensureCheckout,
7
+ } from "../services/plugin-checkout.js";
8
+
9
+ /**
10
+ * A checkout is a read-only shallow clone magus owns, used so that browsing
11
+ * what a plugin contains never requires installing it and never spends the
12
+ * GitHub API budget (60 requests an hour, unauthenticated, shared across every
13
+ * repo on screen).
14
+ */
15
+ describe("checkoutDirName", () => {
16
+ it("flattens owner/repo into one filesystem-safe segment", () => {
17
+ expect(checkoutDirName("addyosmani/agent-skills")).toBe(
18
+ "addyosmani__agent-skills",
19
+ );
20
+ expect(checkoutDirName("MadAppGang/magus")).toBe("MadAppGang__magus");
21
+ });
22
+
23
+ it("never yields a name containing a path separator", () => {
24
+ for (const repo of [
25
+ "a/b",
26
+ "../../etc/passwd",
27
+ "owner/repo.git",
28
+ "weird name/with spaces",
29
+ ]) {
30
+ const name = checkoutDirName(repo);
31
+ expect(name).not.toContain("/");
32
+ expect(name).not.toContain("\\");
33
+ // A traversal attempt must not survive as one: `..` collapses into the
34
+ // same separator run as everything else, so the result cannot escape
35
+ // the checkout root.
36
+ expect(path.basename(name)).toBe(name);
37
+ }
38
+ });
39
+
40
+ it("keeps dots that are part of a real repo name", () => {
41
+ expect(checkoutDirName("owner/repo.js")).toBe("owner__repo.js");
42
+ });
43
+ });
44
+
45
+ describe("checkoutPath", () => {
46
+ it("puts every checkout under one root, outside Claude Code's own dirs", () => {
47
+ const a = checkoutPath("owner/one");
48
+ const b = checkoutPath("owner/two");
49
+
50
+ expect(path.dirname(a)).toBe(path.dirname(b));
51
+ expect(path.dirname(a)).toContain("magus-checkouts");
52
+ // Claude Code owns `plugins/`; a checkout must not look like installed
53
+ // state, or a reader could mistake it for something Claude Code loads.
54
+ expect(a).not.toContain(`${path.sep}plugins${path.sep}`);
55
+ });
56
+ });
57
+
58
+ describe("ensureCheckout", () => {
59
+ it("refuses to clone from a test run", async () => {
60
+ // Not a precaution. A test that only meant to exercise the tree-API path
61
+ // shallow-cloned a real GitHub repo, because stubbing `globalThis.fetch`
62
+ // does not stop `git`.
63
+ expect(process.env.NODE_ENV).toBe("test");
64
+ expect(await ensureCheckout("blader/humanizer")).toBeNull();
65
+ });
66
+ });