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,172 @@
1
+ /**
2
+ * In-memory types and defaults for the managed `claude` alias.
3
+ *
4
+ * Persistence has moved:
5
+ * - Alias name lives in Claude Code's `settings.json` under `magus.aliasName`
6
+ * (read/written via `alias-settings.ts`).
7
+ * - Flag values are read from the user's shell rc file on screen mount
8
+ * (via `parseAliasFromRc` in `alias-shell-writer.ts`). Edits stay in
9
+ * memory until the user presses `w` to write the rc file.
10
+ *
11
+ * This module is filesystem-free so it can be imported by tests and pure
12
+ * logic without dragging in `claude-settings.ts`'s module-level `os.homedir()`
13
+ * constants.
14
+ */
15
+
16
+ import { ALIAS_FLAGS, type AliasFlag } from "../data/alias-flags.js";
17
+
18
+ /**
19
+ * Per-flag value shapes. Discriminator is `kind`, mirroring `AliasFlag.kind`.
20
+ */
21
+ export type FlagValue =
22
+ | { kind: "boolean"; enabled: boolean }
23
+ | { kind: "tri-state"; state: "unset" | "on" | "off" }
24
+ | { kind: "select"; enabled: boolean; value: string }
25
+ | { kind: "text"; enabled: boolean; value: string }
26
+ | { kind: "optional-text"; enabled: boolean; value: string }
27
+ | { kind: "text-list"; enabled: boolean; values: string[] }
28
+ | {
29
+ kind: "multi-with-custom";
30
+ enabled: boolean;
31
+ picked: string[];
32
+ custom: string[];
33
+ };
34
+
35
+ /**
36
+ * In-memory alias configuration. No `version` field since this isn't
37
+ * persisted as-is anymore — the alias name is stored in `settings.json`
38
+ * and flag values come from the rc file.
39
+ */
40
+ export interface AliasConfig {
41
+ /**
42
+ * Name of the shell alias. Default `"c"` so users can keep typing the
43
+ * vanilla `claude` command alongside the wrapped one.
44
+ */
45
+ aliasName: string;
46
+ /** Per-flag state, keyed by `AliasFlag.id`. */
47
+ flags: Record<string, FlagValue>;
48
+ }
49
+
50
+ /** Default alias name. POSIX/fish-safe, short, and not in conflict with `claude`. */
51
+ export const DEFAULT_ALIAS_NAME = "c";
52
+
53
+ /**
54
+ * Validate a candidate alias name. Returns null when valid, or a human
55
+ * reason when invalid.
56
+ *
57
+ * We refuse anything that isn't `[A-Za-z_][A-Za-z0-9_-]*` — that's the
58
+ * intersection of POSIX `name` rules and fish's `alias NAME …` accepting
59
+ * set, and avoids the need to quote the name on either side of the `=`.
60
+ */
61
+ export function validateAliasName(name: string): string | null {
62
+ if (name.length === 0) return "Alias name cannot be empty.";
63
+ if (name.length > 32) return "Alias name is unreasonably long (>32 chars).";
64
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(name)) {
65
+ return "Alias name must start with a letter or underscore and contain only letters, digits, _ or -.";
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * Build the default in-memory config. Used when no managed block exists in
72
+ * the rc file (every flag disabled, default alias name).
73
+ */
74
+ export function defaultAliasConfig(): AliasConfig {
75
+ const flags: Record<string, FlagValue> = {};
76
+ for (const flag of ALIAS_FLAGS) {
77
+ flags[flag.id] = defaultValueFor(flag);
78
+ }
79
+ return { aliasName: DEFAULT_ALIAS_NAME, flags };
80
+ }
81
+
82
+ // ─── Channels ⊇ dev-load (derive-on-display) ───────────────────────────
83
+ //
84
+ // `--channels` is the approved-channel allowlist; `--dangerously-load-
85
+ // development-channels` sideloads channels for local dev. A dev-loaded
86
+ // channel must also run, so at the COMMAND LINE the channels list is the
87
+ // union of both. We do NOT store that union — provenance would be lost
88
+ // (you couldn't tell an own channel from a derived one). Instead:
89
+ // - `channels.values` holds the user's OWN approved channels only.
90
+ // - The dev-load flag is the single source of truth for derived channels.
91
+ // - The editor shows them as a separate read-only section (display).
92
+ // - The writer unions `own ∪ dev-load(if enabled)` at RENDER time.
93
+ // This keeps the two lists distinct everywhere except the emitted alias.
94
+
95
+ /** Id of the channels allowlist flag. */
96
+ export const CHANNELS_FLAG_ID = "channels";
97
+ /** Id of the dev-load flag whose values are derived into channels. */
98
+ export const DEVLOAD_FLAG_ID = "dangerously-load-development-channels";
99
+
100
+ /** Read the value list off a flag value, or `[]` for non-list kinds. */
101
+ export function listValues(v: FlagValue | undefined): string[] {
102
+ return v && v.kind === "text-list" ? v.values : [];
103
+ }
104
+
105
+ /**
106
+ * Channels that `--channels` derives from the dev-load flag for DISPLAY and
107
+ * for the render-time union: the dev-load flag's values, but only when that
108
+ * flag is enabled (a disabled dev-load contributes nothing to the alias).
109
+ */
110
+ export function derivedChannelValues(
111
+ flags: Record<string, FlagValue>,
112
+ ): string[] {
113
+ const devload = flags[DEVLOAD_FLAG_ID];
114
+ if (!devload || devload.kind !== "text-list" || !devload.enabled) return [];
115
+ return devload.values;
116
+ }
117
+
118
+ /**
119
+ * Return a new flags map with `value` removed from the dev-load flag (the
120
+ * SOURCE of derived channels). Used when the user deletes a "Dev-loaded
121
+ * channels" row inside the --channels editor: the deletion must land on the
122
+ * source flag, never on `channels.values`. Disables dev-load when emptied so
123
+ * it stops emitting. Pure — leaves `channels.values` untouched.
124
+ */
125
+ export function withoutDerivedChannel(
126
+ flags: Record<string, FlagValue>,
127
+ value: string,
128
+ ): Record<string, FlagValue> {
129
+ const cur = flags[DEVLOAD_FLAG_ID];
130
+ if (!cur || cur.kind !== "text-list") return flags;
131
+ const values = cur.values.filter((v) => v !== value);
132
+ return {
133
+ ...flags,
134
+ [DEVLOAD_FLAG_ID]: {
135
+ kind: "text-list",
136
+ enabled: values.length > 0 && cur.enabled,
137
+ values,
138
+ },
139
+ };
140
+ }
141
+
142
+ export function defaultValueFor(flag: AliasFlag): FlagValue {
143
+ switch (flag.kind) {
144
+ case "boolean":
145
+ return { kind: "boolean", enabled: false };
146
+ case "tri-state":
147
+ return { kind: "tri-state", state: "unset" };
148
+ case "select":
149
+ return {
150
+ kind: "select",
151
+ enabled: false,
152
+ value: flag.options?.[0]?.value ?? "",
153
+ };
154
+ case "text":
155
+ return { kind: "text", enabled: false, value: "" };
156
+ case "optional-text":
157
+ return { kind: "optional-text", enabled: false, value: "" };
158
+ case "text-list":
159
+ return {
160
+ kind: "text-list",
161
+ enabled: false,
162
+ values: [...(flag.defaultValues ?? [])],
163
+ };
164
+ case "multi-with-custom":
165
+ return {
166
+ kind: "multi-with-custom",
167
+ enabled: false,
168
+ picked: [],
169
+ custom: [...(flag.defaultValues ?? [])],
170
+ };
171
+ }
172
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Code-sign a compiled magus binary, and refuse to ship one that does not
3
+ * verify.
4
+ *
5
+ * `bun build --compile` produces a Mach-O carrying an ad-hoc, linker-signed
6
+ * signature (`flags=0x20002(adhoc,linker-signed)`) and then appends the
7
+ * JavaScript bundle to the end of the file. The signature therefore describes
8
+ * a shorter file than the one that ships. `codesign -v` has reported
9
+ * "invalid signature (code or signature have been modified)" on every
10
+ * published magus binary back to 4.22.0.
11
+ *
12
+ * macOS 27 refuses to exec such a binary: SIGKILL at startup, no output. The
13
+ * build never signed anything, so nothing caught it — `codesign` appeared
14
+ * nowhere in this package.
15
+ *
16
+ * Signing is ad-hoc (`--sign -`) rather than Developer ID on purpose: files
17
+ * installed by npm are not quarantined, so Gatekeeper never evaluates them.
18
+ * The kernel's signature check is the only gate that was failing, and an
19
+ * ad-hoc signature satisfies it — with no Apple account, no CI secrets and no
20
+ * notarization wait.
21
+ */
22
+
23
+ import { spawnSync } from "node:child_process";
24
+
25
+ /** Result of one external command. Injected so the logic is testable. */
26
+ export type Run = (
27
+ cmd: string,
28
+ args: string[],
29
+ ) => { status: number | null; stderr: string };
30
+
31
+ const realRun: Run = (cmd, args) => {
32
+ const result = spawnSync(cmd, args, { encoding: "utf8" });
33
+ // Fail closed and say why. A missing `codesign` must stop the build, not
34
+ // quietly skip signing — and the raw ENOENT names neither the binary being
35
+ // signed nor the reason signing is mandatory.
36
+ if (result.error) {
37
+ throw new Error(
38
+ `Could not run \`${cmd}\` while signing ${args[args.length - 1]}: ${result.error.message}\nSigning is mandatory for darwin binaries — macOS kills an unsigned one at exec.`,
39
+ );
40
+ }
41
+ return { status: result.status, stderr: result.stderr ?? "" };
42
+ };
43
+
44
+ /**
45
+ * Only darwin. `codesign` does not exist elsewhere, and no other platform
46
+ * validates a signature at exec.
47
+ */
48
+ export const needsSigning = (target: { os: string }): boolean =>
49
+ target.os === "darwin";
50
+
51
+ /**
52
+ * Sign the binary, then verify it. A failing verify THROWS.
53
+ *
54
+ * This is a gate, not an advisory. The whole defect being fixed is a release
55
+ * that shipped a binary whose signature did not match it; a warning here would
56
+ * reproduce that exactly, one log line louder.
57
+ */
58
+ export function signAndVerify(
59
+ target: { os: string },
60
+ binPath: string,
61
+ run: Run = realRun,
62
+ ): void {
63
+ if (!needsSigning(target)) return;
64
+
65
+ const signed = run("codesign", ["--force", "--sign", "-", binPath]);
66
+ if (signed.status !== 0) {
67
+ throw new Error(
68
+ `codesign --sign failed for ${binPath} (exit ${signed.status})\n${signed.stderr}`,
69
+ );
70
+ }
71
+
72
+ const verified = run("codesign", ["--verify", "--strict", binPath]);
73
+ if (verified.status !== 0) {
74
+ throw new Error(
75
+ `codesign --verify --strict rejected ${binPath} (exit ${verified.status}).\nRefusing to ship a binary macOS will kill at exec.\n${verified.stderr}`,
76
+ );
77
+ }
78
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * catalog-cache-store.ts — the catalog cache and the rate-limit cooldown, on disk.
3
+ *
4
+ * Why on disk
5
+ * -----------
6
+ * Every cache in this area used to be a module-level `Map`, which for a TUI means
7
+ * "cleared on every launch". Measured 2026-08-17, two consecutive processes:
8
+ *
9
+ * [process-1] 6 HTTP requests in 13.32s
10
+ * [process-2] 6 HTTP requests in 13.01s
11
+ *
12
+ * So each launch re-fetched all six catalogs, and the second process had no idea
13
+ * the first had just been rate-limited — it spent six more requests rediscovering
14
+ * that. On some endpoints a rejected request extends the penalty, so an in-memory
15
+ * cooldown is not merely useless across launches, it is counterproductive.
16
+ *
17
+ * Persisting the cooldown is the more important half. A stale catalog costs the
18
+ * user a version number that is an hour old; re-firing six doomed requests on
19
+ * every launch costs them the ability to check at all.
20
+ *
21
+ * Trade-off, stated
22
+ * -----------------
23
+ * A cached catalog means a release published inside the TTL is not visible yet.
24
+ * That is why the TTL is short, why `r` forces a real refetch, and why the age is
25
+ * available for display. A cache the user cannot see through or override would be
26
+ * the same trap as a frozen clone.
27
+ */
28
+
29
+ import { promises as fs } from "node:fs";
30
+ import path from "node:path";
31
+ import { claudeConfigDirOrNull } from "../utils/config-dir.js";
32
+ import { withFileLock } from "../utils/file-locking.js";
33
+ import type { MarketplacePlugin } from "./marketplace-catalog.js";
34
+
35
+ /**
36
+ * How long a cached catalog is served before we ask again.
37
+ *
38
+ * This is the ONLY refresh rhythm magus has, and it is why the TUI and
39
+ * `magus update` can disagree about the same plugin for up to an hour:
40
+ * `update` fast-forwards the clones and clears this cache before planning,
41
+ * while the TUI serves whatever is here. That is not a bug in either path, but
42
+ * anything that reports "no update" off a cached catalog has to say the answer
43
+ * is up to an hour old.
44
+ */
45
+ export const CATALOG_TTL_MS = 60 * 60 * 1000;
46
+
47
+ interface StoredCatalog {
48
+ fetchedAt: number;
49
+ /** Which transport answered — carried so the UI can still say "unverified". */
50
+ source: string;
51
+ plugins: MarketplacePlugin[];
52
+ }
53
+
54
+ interface StoredCooldown {
55
+ until: number;
56
+ exact: boolean;
57
+ strikes: number;
58
+ }
59
+
60
+ /**
61
+ * What the last upstream check learned about one community-styles repository.
62
+ *
63
+ * Lives here rather than in a fourth cache file so it inherits this store's file
64
+ * lock, atomic write and CLAUDE_CONFIG_DIR test isolation — and, more to the
65
+ * point, so it sits beside the rate-limit cooldowns it is budgeted against.
66
+ *
67
+ * `dirHeadSha` is the pivot of the two-phase check: one API call per REPO
68
+ * answers "did anything in its styles directory move", and only a repo that
69
+ * moved is drilled into with free raw fetches. Per-style API calls would spend
70
+ * the machine's whole hourly budget on first use.
71
+ */
72
+ export interface StoredCommunityCheck {
73
+ /** Wall-clock ms of the last successful phase A. Drives the 24h TTL. */
74
+ checkedAt: number;
75
+ /** Latest commit touching the repo's styles directory, as of that check. */
76
+ dirHeadSha: string;
77
+ /** Per coordinate id: the bytes we hold, and any update sitting in .pending/. */
78
+ styles: Record<
79
+ string,
80
+ {
81
+ sha256: string;
82
+ pendingSha256?: string;
83
+ /** Changed-line count of that pending update, so it survives a restart. */
84
+ pendingLines?: number;
85
+ }
86
+ >;
87
+ }
88
+
89
+ interface StoreShape {
90
+ version: 1;
91
+ catalogs: Record<string, StoredCatalog>;
92
+ cooldowns: Record<string, StoredCooldown>;
93
+ communityStyles: Record<string, StoredCommunityCheck>;
94
+ }
95
+
96
+ const EMPTY: StoreShape = {
97
+ version: 1,
98
+ catalogs: {},
99
+ cooldowns: {},
100
+ communityStyles: {},
101
+ };
102
+
103
+ /**
104
+ * Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
105
+ * content-drift.ts and marketplace-catalog-git.ts.
106
+ *
107
+ * Null when a test has not chosen a config dir, which makes this whole store inert
108
+ * rather than letting an unisolated test read the operator's live cooldown and
109
+ * decide its own outcome from it. See utils/config-dir.ts.
110
+ */
111
+ function storePath(): string | null {
112
+ const configDir = claudeConfigDirOrNull();
113
+ if (!configDir) return null;
114
+ return path.join(configDir, "magus-catalog-cache.json");
115
+ }
116
+
117
+ /**
118
+ * Read-through memo for this process. The disk file is the cross-launch cache;
119
+ * this just avoids re-reading and re-parsing it on every lookup within one run.
120
+ */
121
+ let memo: { path: string; data: StoreShape } | null = null;
122
+
123
+ async function load(): Promise<StoreShape> {
124
+ const file = storePath();
125
+ if (!file)
126
+ return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
127
+ if (memo?.path === file) return memo.data;
128
+ try {
129
+ const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
130
+ // A cache from a future/unknown layout is discarded rather than migrated:
131
+ // it is regenerable by definition, so guessing at it buys nothing.
132
+ const data = parsed?.version === 1 ? parsed : { ...EMPTY };
133
+ data.catalogs ??= {};
134
+ data.cooldowns ??= {};
135
+ // Absent in every file written before community styles existed, so this
136
+ // default is the migration — an upgrade must not read as a corrupt cache.
137
+ data.communityStyles ??= {};
138
+ memo = { path: file, data };
139
+ return data;
140
+ } catch {
141
+ // Absent or corrupt. A cache must never be a failure mode — start empty.
142
+ const data: StoreShape = {
143
+ ...EMPTY,
144
+ catalogs: {},
145
+ cooldowns: {},
146
+ communityStyles: {},
147
+ };
148
+ memo = { path: file, data };
149
+ return data;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Apply a mutation and persist it.
155
+ *
156
+ * Locked and re-read inside the lock, because two magus processes can be open
157
+ * at once and a blind read-modify-write would drop the other's cooldown — exactly
158
+ * the record we most need to keep.
159
+ */
160
+ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
161
+ const file = storePath();
162
+ if (!file) return;
163
+ try {
164
+ await fs.mkdir(path.dirname(file), { recursive: true });
165
+ await withFileLock(file, async () => {
166
+ let onDisk: StoreShape;
167
+ try {
168
+ const parsed = JSON.parse(
169
+ await fs.readFile(file, "utf-8"),
170
+ ) as StoreShape;
171
+ // Every section is named explicitly: a key omitted here is silently
172
+ // dropped on the next write of any OTHER section.
173
+ onDisk =
174
+ parsed?.version === 1
175
+ ? {
176
+ version: 1,
177
+ catalogs: parsed.catalogs ?? {},
178
+ cooldowns: parsed.cooldowns ?? {},
179
+ communityStyles: parsed.communityStyles ?? {},
180
+ }
181
+ : { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
182
+ } catch {
183
+ onDisk = {
184
+ version: 1,
185
+ catalogs: {},
186
+ cooldowns: {},
187
+ communityStyles: {},
188
+ };
189
+ }
190
+ fn(onDisk);
191
+ // Temp + rename, so a crash mid-write cannot leave a half-written file
192
+ // that the next launch has to treat as corrupt.
193
+ const tmp = `${file}.${process.pid}.tmp`;
194
+ await fs.writeFile(tmp, JSON.stringify(onDisk), "utf-8");
195
+ await fs.rename(tmp, file);
196
+ memo = { path: file, data: onDisk };
197
+ });
198
+ } catch {
199
+ // Never let a cache write break the run. Worst case we re-fetch next time.
200
+ }
201
+ }
202
+
203
+ // ─── Catalogs ────────────────────────────────────────────────────────────────
204
+
205
+ export interface CachedCatalog {
206
+ plugins: MarketplacePlugin[];
207
+ source: string;
208
+ /** Milliseconds since it was fetched — for display, and for the TTL test. */
209
+ ageMs: number;
210
+ }
211
+
212
+ /** A cached catalog for this marketplace, if one is present and still fresh. */
213
+ export async function readCachedCatalog(
214
+ marketplace: string,
215
+ now: number = Date.now(),
216
+ ): Promise<CachedCatalog | null> {
217
+ const data = await load();
218
+ const entry = data.catalogs[marketplace];
219
+ if (!entry) return null;
220
+ const ageMs = now - entry.fetchedAt;
221
+ if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
222
+ return { plugins: entry.plugins, source: entry.source, ageMs };
223
+ }
224
+
225
+ export async function writeCachedCatalog(
226
+ marketplace: string,
227
+ plugins: MarketplacePlugin[],
228
+ source: string,
229
+ now: number = Date.now(),
230
+ ): Promise<void> {
231
+ // An empty catalog is never worth caching: it is indistinguishable from a
232
+ // failure we did not classify, and serving it would resurrect the original bug.
233
+ if (plugins.length === 0) return;
234
+ await mutate((data) => {
235
+ data.catalogs[marketplace] = { fetchedAt: now, source, plugins };
236
+ });
237
+ }
238
+
239
+ /** Drop every cached catalog. Backs the explicit refresh (`r`). */
240
+ export async function clearCachedCatalogs(): Promise<void> {
241
+ await mutate((data) => {
242
+ data.catalogs = {};
243
+ });
244
+ }
245
+
246
+ // ─── Cooldowns ───────────────────────────────────────────────────────────────
247
+
248
+ export async function readStoredCooldowns(
249
+ now: number = Date.now(),
250
+ ): Promise<Record<string, StoredCooldown>> {
251
+ const data = await load();
252
+ const live: Record<string, StoredCooldown> = {};
253
+ for (const [host, cooldown] of Object.entries(data.cooldowns)) {
254
+ // Expired entries are dropped on read rather than persisted forever. The
255
+ // strike count goes with them: a cooldown that lapsed a day ago should not
256
+ // make today's first failure back off for ten minutes.
257
+ if (cooldown.until > now) live[host] = cooldown;
258
+ }
259
+ return live;
260
+ }
261
+
262
+ export async function writeStoredCooldown(
263
+ host: string,
264
+ cooldown: StoredCooldown,
265
+ ): Promise<void> {
266
+ await mutate((data) => {
267
+ data.cooldowns[host] = cooldown;
268
+ });
269
+ }
270
+
271
+ export async function clearStoredCooldown(host: string): Promise<void> {
272
+ await mutate((data) => {
273
+ delete data.cooldowns[host];
274
+ });
275
+ }
276
+
277
+ // ─── Community style checks ──────────────────────────────────────────────────
278
+
279
+ /**
280
+ * Every recorded upstream check, keyed by community source id.
281
+ *
282
+ * Deliberately NOT expired on read, unlike cooldowns. An expired cooldown means
283
+ * "you may call again"; an expired CHECK still carries the shas we compare
284
+ * against, and only its `checkedAt` has gone stale. Dropping it would throw away
285
+ * the one thing that makes the next check cheap, and would make a 25-hour-old
286
+ * result indistinguishable from never having looked. Freshness is judged by the
287
+ * reader, which is what lets an expired check present as `unknown` rather than
288
+ * as up to date.
289
+ */
290
+ export async function readCommunityChecks(): Promise<
291
+ Record<string, StoredCommunityCheck>
292
+ > {
293
+ return { ...(await load()).communityStyles };
294
+ }
295
+
296
+ export async function writeCommunityCheck(
297
+ sourceId: string,
298
+ check: StoredCommunityCheck,
299
+ ): Promise<void> {
300
+ await mutate((data) => {
301
+ data.communityStyles ??= {};
302
+ data.communityStyles[sourceId] = check;
303
+ });
304
+ }
305
+
306
+ /** Drop every recorded check. Backs an explicit refresh. */
307
+ export async function clearCommunityChecks(): Promise<void> {
308
+ await mutate((data) => {
309
+ data.communityStyles = {};
310
+ });
311
+ }
312
+
313
+ /** Test seam: forget this process's memo so the next read hits disk. */
314
+ export function resetCatalogCacheMemo(): void {
315
+ memo = null;
316
+ }