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,225 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import type { Profile, ProfileEntry, ProfilesFile } from "../types/index.js";
5
+
6
+ const PROFILES_FILE = "profiles.json";
7
+
8
+ // ─── Path helpers ──────────────────────────────────────────────────────────────
9
+
10
+ export function getUserProfilesPath(): string {
11
+ return path.join(os.homedir(), ".claude", PROFILES_FILE);
12
+ }
13
+
14
+ export function getProjectProfilesPath(projectPath?: string): string {
15
+ const base = projectPath ?? process.cwd();
16
+ return path.join(base, ".claude", PROFILES_FILE);
17
+ }
18
+
19
+ // ─── Low-level read/write ──────────────────────────────────────────────────────
20
+
21
+ export async function readProfiles(
22
+ scope: "user" | "project",
23
+ projectPath?: string,
24
+ ): Promise<ProfilesFile> {
25
+ const filePath =
26
+ scope === "user"
27
+ ? getUserProfilesPath()
28
+ : getProjectProfilesPath(projectPath);
29
+ try {
30
+ if (await fs.pathExists(filePath)) {
31
+ const data = await fs.readJson(filePath);
32
+ if (data.version && data.profiles) return data as ProfilesFile;
33
+ }
34
+ } catch {
35
+ // fall through to default
36
+ }
37
+ return { version: 1, profiles: {} };
38
+ }
39
+
40
+ async function writeProfiles(
41
+ scope: "user" | "project",
42
+ data: ProfilesFile,
43
+ projectPath?: string,
44
+ ): Promise<void> {
45
+ const filePath =
46
+ scope === "user"
47
+ ? getUserProfilesPath()
48
+ : getProjectProfilesPath(projectPath);
49
+ await fs.ensureDir(path.dirname(filePath));
50
+ await fs.writeJson(filePath, data, { spaces: 2 });
51
+ }
52
+
53
+ // ─── CRUD ──────────────────────────────────────────────────────────────────────
54
+
55
+ /** Return all profiles from both scopes, merged into a flat list */
56
+ export async function listProfiles(
57
+ projectPath?: string,
58
+ ): Promise<ProfileEntry[]> {
59
+ const [user, project] = await Promise.all([
60
+ readProfiles("user"),
61
+ readProfiles("project", projectPath),
62
+ ]);
63
+ const entries: ProfileEntry[] = [];
64
+ for (const [id, p] of Object.entries(user.profiles)) {
65
+ entries.push({ id, scope: "user", ...p });
66
+ }
67
+ for (const [id, p] of Object.entries(project.profiles)) {
68
+ entries.push({ id, scope: "project", ...p });
69
+ }
70
+ // Sort: user profiles first, then project, each by updatedAt desc
71
+ entries.sort((a, b) => {
72
+ if (a.scope !== b.scope) return a.scope === "user" ? -1 : 1;
73
+ return b.updatedAt.localeCompare(a.updatedAt);
74
+ });
75
+ return entries;
76
+ }
77
+
78
+ /**
79
+ * Save (create or overwrite) a profile.
80
+ * Returns the generated profile ID.
81
+ */
82
+ export async function saveProfile(
83
+ name: string,
84
+ plugins: Record<string, boolean>,
85
+ scope: "user" | "project",
86
+ projectPath?: string,
87
+ ): Promise<string> {
88
+ const data = await readProfiles(scope, projectPath);
89
+ const id = generateUniqueId(name, data.profiles);
90
+ const now = new Date().toISOString();
91
+ data.profiles[id] = {
92
+ name,
93
+ plugins,
94
+ createdAt: now,
95
+ updatedAt: now,
96
+ };
97
+ await writeProfiles(scope, data, projectPath);
98
+ return id;
99
+ }
100
+
101
+ /**
102
+ * Apply a profile: replaces enabledPlugins in the target settings scope.
103
+ * targetScope is where the settings will be written, independent of where
104
+ * the profile is stored.
105
+ */
106
+ export async function applyProfile(
107
+ profileId: string,
108
+ profileScope: "user" | "project",
109
+ targetScope: "user" | "project" | "local",
110
+ projectPath?: string,
111
+ ): Promise<void> {
112
+ const data = await readProfiles(profileScope, projectPath);
113
+ const profile = data.profiles[profileId];
114
+ if (!profile) throw new Error(`Profile "${profileId}" not found`);
115
+
116
+ // Import settings functions dynamically to avoid circular deps
117
+ const {
118
+ readSettings,
119
+ writeSettings,
120
+ readGlobalSettings,
121
+ writeGlobalSettings,
122
+ } = await import("./claude-settings.js");
123
+
124
+ if (targetScope === "user") {
125
+ const settings = await readGlobalSettings();
126
+ settings.enabledPlugins = { ...profile.plugins };
127
+ await writeGlobalSettings(settings);
128
+ } else {
129
+ // project or local both write to project settings.json
130
+ const settings = await readSettings(projectPath);
131
+ settings.enabledPlugins = { ...profile.plugins };
132
+ await writeSettings(settings, projectPath);
133
+ }
134
+ }
135
+
136
+ /** Rename a profile in-place (preserves id, createdAt) */
137
+ export async function renameProfile(
138
+ profileId: string,
139
+ newName: string,
140
+ scope: "user" | "project",
141
+ projectPath?: string,
142
+ ): Promise<void> {
143
+ const data = await readProfiles(scope, projectPath);
144
+ if (!data.profiles[profileId]) {
145
+ throw new Error(`Profile "${profileId}" not found in ${scope} scope`);
146
+ }
147
+ data.profiles[profileId].name = newName;
148
+ data.profiles[profileId].updatedAt = new Date().toISOString();
149
+ await writeProfiles(scope, data, projectPath);
150
+ }
151
+
152
+ export async function deleteProfile(
153
+ profileId: string,
154
+ scope: "user" | "project",
155
+ projectPath?: string,
156
+ ): Promise<void> {
157
+ const data = await readProfiles(scope, projectPath);
158
+ if (!data.profiles[profileId]) {
159
+ throw new Error(`Profile "${profileId}" not found in ${scope} scope`);
160
+ }
161
+ delete data.profiles[profileId];
162
+ await writeProfiles(scope, data, projectPath);
163
+ }
164
+
165
+ // ─── Import / Export ───────────────────────────────────────────────────────────
166
+
167
+ /** Serialize a profile to a compact JSON string for clipboard sharing */
168
+ export async function exportProfileToJson(
169
+ profileId: string,
170
+ scope: "user" | "project",
171
+ projectPath?: string,
172
+ ): Promise<string> {
173
+ const data = await readProfiles(scope, projectPath);
174
+ const profile = data.profiles[profileId];
175
+ if (!profile) throw new Error(`Profile "${profileId}" not found`);
176
+ const entry: ProfileEntry = { id: profileId, scope, ...profile };
177
+ return JSON.stringify(entry, null, 2);
178
+ }
179
+
180
+ /**
181
+ * Parse a clipboard JSON string and save into the specified scope.
182
+ * Returns the saved profile id.
183
+ */
184
+ export async function importProfileFromJson(
185
+ json: string,
186
+ targetScope: "user" | "project",
187
+ projectPath?: string,
188
+ ): Promise<string> {
189
+ let entry: Partial<ProfileEntry>;
190
+ try {
191
+ entry = JSON.parse(json) as Partial<ProfileEntry>;
192
+ } catch {
193
+ throw new Error("Invalid JSON — could not parse clipboard content");
194
+ }
195
+ if (!entry.name || typeof entry.plugins !== "object") {
196
+ throw new Error("Invalid profile format — expected { name, plugins }");
197
+ }
198
+ return saveProfile(
199
+ entry.name,
200
+ entry.plugins as Record<string, boolean>,
201
+ targetScope,
202
+ projectPath,
203
+ );
204
+ }
205
+
206
+ // ─── Internal helpers ──────────────────────────────────────────────────────────
207
+
208
+ function slugify(name: string): string {
209
+ return name
210
+ .toLowerCase()
211
+ .trim()
212
+ .replace(/[^a-z0-9]+/g, "-")
213
+ .replace(/^-|-$/g, "");
214
+ }
215
+
216
+ function generateUniqueId(
217
+ name: string,
218
+ existing: Record<string, unknown>,
219
+ ): string {
220
+ const base = slugify(name) || "profile";
221
+ if (!existing[base]) return base;
222
+ let n = 2;
223
+ while (existing[`${base}-${n}`]) n++;
224
+ return `${base}-${n}`;
225
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Profile resolver — compute the install closure for a team profile.
3
+ *
4
+ * Turns a manifest entry into a fully-resolved {@link ResolvedClosure}: the
5
+ * marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
6
+ * machine must have to match the profile. Binary deps are the union of the
7
+ * profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
8
+ * deduped by executable name with the project's pin winning.
9
+ */
10
+
11
+ import semver from "semver";
12
+ import { cliTools } from "../data/cli-tools.js";
13
+ import { getMarketplaceByName } from "../data/marketplaces.js";
14
+ import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
15
+ import type {
16
+ BinRequirement,
17
+ ProfileManifest,
18
+ ProfileManifestEntry,
19
+ ResolvedBin,
20
+ ResolvedClosure,
21
+ } from "../types/index.js";
22
+ import { resolvePluginBinRequirements } from "./plugin-requires.js";
23
+
24
+ export interface ResolveOptions {
25
+ /**
26
+ * Resolve a plugin id to its declared binary requirements. Injectable for
27
+ * tests; defaults to reading plugin.json from the marketplace cache.
28
+ */
29
+ binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
30
+ }
31
+
32
+ /** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
33
+ function normalizePlugins(
34
+ plugins: Record<string, string | boolean> | undefined,
35
+ ): Record<string, string> {
36
+ const out: Record<string, string> = {};
37
+ for (const [id, ver] of Object.entries(plugins ?? {})) {
38
+ if (ver === false) continue; // explicitly disabled
39
+ out[id] = ver === true || ver === "" ? "latest" : String(ver);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Merge a predefined profile (referenced via `extends`) UNDER an entry: the
46
+ * entry's own plugins/settings take precedence. Predefined skills are bare
47
+ * display names without repo/path, so they are NOT installable and are skipped.
48
+ */
49
+ export function resolveExtends(
50
+ entry: ProfileManifestEntry,
51
+ ): ProfileManifestEntry {
52
+ if (!entry.extends) return entry;
53
+ const base = PREDEFINED_PROFILES.find((p) => p.id === entry.extends);
54
+ if (!base) return entry;
55
+
56
+ const inheritedPlugins: Record<string, string | boolean> = {};
57
+ for (const name of base.magusPlugins) {
58
+ // Entries may pin a marketplace explicitly (e.g. "image-generate@magus-marketing")
59
+ // for plugins that ship on a channel other than magus. Bare names
60
+ // still default to @magus.
61
+ inheritedPlugins[name.includes("@") ? name : `${name}@magus`] = "latest";
62
+ }
63
+ for (const name of base.anthropicPlugins) {
64
+ inheritedPlugins[`${name}@claude-plugins-official`] = "latest";
65
+ }
66
+
67
+ return {
68
+ ...entry,
69
+ // Entry values win over inherited ones.
70
+ plugins: { ...inheritedPlugins, ...(entry.plugins ?? {}) },
71
+ settings: { ...base.settings, ...(entry.settings ?? {}) },
72
+ // `models` inherits by whole object, not key-by-key: a ModelsConfig is
73
+ // validated as a complete unit (every grade present, one effort per
74
+ // model), so a half-inherited merge could produce a config neither side
75
+ // wrote and neither side validated.
76
+ ...((entry.models ?? base.models)
77
+ ? { models: entry.models ?? base.models }
78
+ : {}),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Map a manifest cliTools entry (name -> version) to a BinRequirement.
84
+ *
85
+ * Exported so the TUI's CLI Tools screen maps a catalogue tool the same way the
86
+ * resolver does, rather than keeping a second copy of the mapping.
87
+ */
88
+ export function cliToolToBin(name: string, version: string): BinRequirement {
89
+ const tool = cliTools.find((t) => t.name === name);
90
+ const pinned = version && version !== "latest" ? version : undefined;
91
+ if (tool) {
92
+ return {
93
+ name,
94
+ via: tool.packageManager,
95
+ package: tool.packageName,
96
+ check: tool.checkCommand,
97
+ version: pinned,
98
+ };
99
+ }
100
+ // Unknown tool: default to bun (magus's toolchain) with name as package.
101
+ return {
102
+ name,
103
+ via: "bun",
104
+ package: name,
105
+ check: `${name} --version`,
106
+ version: pinned,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Dedupe binary requirements by executable name. Entries are processed in the
112
+ * order given, so callers put higher-precedence sources first (project pins
113
+ * before plugin-declared deps); the first entry's installer/version wins and
114
+ * later ones only contribute provenance.
115
+ */
116
+ export function dedupeBins(
117
+ entries: Array<{ bin: BinRequirement; source: string }>,
118
+ ): ResolvedBin[] {
119
+ const byName = new Map<string, ResolvedBin>();
120
+ for (const { bin, source } of entries) {
121
+ const existing = byName.get(bin.name);
122
+ if (existing) {
123
+ if (!existing.sources.includes(source)) existing.sources.push(source);
124
+ // A later entry only fills a version the winner left unset.
125
+ if (existing.version === undefined && bin.version !== undefined) {
126
+ existing.version = bin.version;
127
+ }
128
+ continue;
129
+ }
130
+ byName.set(bin.name, { ...bin, sources: [source] });
131
+ }
132
+ return [...byName.values()];
133
+ }
134
+
135
+ /** Collect the binary requirements for a resolved plugin set + profile cliTools. */
136
+ async function resolveBins(
137
+ entry: ProfileManifestEntry,
138
+ pluginIds: string[],
139
+ binResolver: (pluginId: string) => Promise<BinRequirement[]>,
140
+ ): Promise<ResolvedBin[]> {
141
+ const collected: Array<{ bin: BinRequirement; source: string }> = [];
142
+
143
+ // Project pins first (highest precedence).
144
+ for (const [name, version] of Object.entries(entry.cliTools ?? {})) {
145
+ collected.push({
146
+ bin: cliToolToBin(name, version),
147
+ source: `profile:${entry.name}.cliTools`,
148
+ });
149
+ }
150
+
151
+ // Then each plugin's declared binary deps.
152
+ for (const pluginId of pluginIds) {
153
+ const bins = await binResolver(pluginId);
154
+ for (const bin of bins) {
155
+ collected.push({ bin, source: `plugin:${pluginId}` });
156
+ }
157
+ }
158
+
159
+ return dedupeBins(collected);
160
+ }
161
+
162
+ /** Resolve a single profile (applying `extends`) into an install closure. */
163
+ /**
164
+ * Add the marketplace every plugin id already names.
165
+ *
166
+ * A plugin id carries its marketplace — `dev@magus`, `image-generate@magus-marketing` —
167
+ * so requiring the manifest to ALSO declare that marketplace is redundant, and
168
+ * forgetting to is silent: the plugin resolves against a marketplace that was
169
+ * never registered. `extends` made this the default failure, because it
170
+ * inherits plugins from a predefined profile and no marketplaces at all.
171
+ *
172
+ * Only marketplaces magus already knows are derived, since a repo is needed
173
+ * to register one. An unknown suffix is left alone for the manifest to declare.
174
+ * An explicit entry always wins: deriving must never override a pin.
175
+ */
176
+ export function withDerivedMarketplaces(
177
+ declared: ProfileManifestEntry["marketplaces"],
178
+ plugins: Record<string, string>,
179
+ ): NonNullable<ProfileManifestEntry["marketplaces"]> {
180
+ const out = { ...(declared ?? {}) };
181
+ for (const id of Object.keys(plugins)) {
182
+ const at = id.lastIndexOf("@");
183
+ if (at <= 0) continue;
184
+ const name = id.slice(at + 1);
185
+ if (out[name]) continue;
186
+ const known = getMarketplaceByName(name);
187
+ if (!known?.source.repo) continue;
188
+ out[name] = { source: "github", repo: known.source.repo };
189
+ }
190
+ return out;
191
+ }
192
+
193
+ export async function resolveProfile(
194
+ manifest: ProfileManifest,
195
+ profileId: string,
196
+ opts: ResolveOptions = {},
197
+ ): Promise<ResolvedClosure> {
198
+ const raw = manifest.profiles[profileId];
199
+ if (!raw) throw new Error(`Profile "${profileId}" not found in manifest`);
200
+ const entry = resolveExtends(raw);
201
+ const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
202
+
203
+ const plugins = normalizePlugins(entry.plugins);
204
+ const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
205
+
206
+ return {
207
+ marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
208
+ plugins,
209
+ mcpServers: { ...(entry.mcpServers ?? {}) },
210
+ bins,
211
+ skills: [...(entry.skills ?? [])],
212
+ settings: { ...(entry.settings ?? {}) },
213
+ ...(entry.models ? { models: entry.models } : {}),
214
+ env: {
215
+ required: [...(entry.env?.required ?? [])],
216
+ optional: [...(entry.env?.optional ?? [])],
217
+ },
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Deterministically pick between two version specs for the same id. "latest"
223
+ * always yields to an explicit pin; two DIFFERENT explicit pins are a conflict,
224
+ * resolved to the higher semver (order-independent) and reported.
225
+ */
226
+ function pickVersion(
227
+ id: string,
228
+ a: string,
229
+ b: string,
230
+ ): { chosen: string; conflict?: string } {
231
+ if (a === b) return { chosen: a };
232
+ if (a === "latest") return { chosen: b };
233
+ if (b === "latest") return { chosen: a };
234
+ const higher =
235
+ semver.valid(a) && semver.valid(b)
236
+ ? semver.gt(a, b)
237
+ ? a
238
+ : b
239
+ : a > b
240
+ ? a
241
+ : b; // lexical fallback for non-semver pins
242
+ return {
243
+ chosen: higher,
244
+ conflict: `${id}: profiles pin ${a} and ${b}; using ${higher}`,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Merge closure `b` into `a` (union). Version conflicts (plugins and bins) are
250
+ * resolved deterministically to the higher pin — NOT by profile order — and
251
+ * collected into `conflicts` so `install`/`profile show` can surface them.
252
+ */
253
+ function mergeClosures(
254
+ a: ResolvedClosure,
255
+ b: ResolvedClosure,
256
+ ): ResolvedClosure {
257
+ const conflicts = [...(a.conflicts ?? []), ...(b.conflicts ?? [])];
258
+
259
+ // Plugins: deterministic version pick per id.
260
+ const plugins: Record<string, string> = { ...a.plugins };
261
+ for (const [id, ver] of Object.entries(b.plugins)) {
262
+ const existing = plugins[id];
263
+ if (existing === undefined) {
264
+ plugins[id] = ver;
265
+ continue;
266
+ }
267
+ const { chosen, conflict } = pickVersion(id, existing, ver);
268
+ plugins[id] = chosen;
269
+ if (conflict) conflicts.push(conflict);
270
+ }
271
+
272
+ // Bins: union by name, deterministic version pick; keep the winning entry's
273
+ // installer/fields and accumulate provenance from both.
274
+ const binByName = new Map<string, ResolvedBin>();
275
+ for (const bin of [...a.bins, ...b.bins]) {
276
+ const existing = binByName.get(bin.name);
277
+ if (!existing) {
278
+ binByName.set(bin.name, { ...bin, sources: [...bin.sources] });
279
+ continue;
280
+ }
281
+ const mergedSources = [...new Set([...existing.sources, ...bin.sources])];
282
+ const va = existing.version;
283
+ const vb = bin.version;
284
+ if (va === undefined && vb === undefined) {
285
+ existing.sources = mergedSources;
286
+ } else if (va === undefined) {
287
+ binByName.set(bin.name, { ...bin, sources: mergedSources });
288
+ } else if (vb === undefined) {
289
+ existing.sources = mergedSources;
290
+ } else {
291
+ const { chosen, conflict } = pickVersion(bin.name, va, vb);
292
+ const winner = chosen === va ? existing : bin;
293
+ binByName.set(bin.name, { ...winner, sources: mergedSources });
294
+ if (conflict) conflicts.push(conflict);
295
+ }
296
+ }
297
+
298
+ // Skills: union by repo+path.
299
+ const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
300
+ const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
301
+ for (const s of a.skills) skillMap.set(skillKey(s), s);
302
+
303
+ return {
304
+ marketplaces: { ...b.marketplaces, ...a.marketplaces },
305
+ plugins,
306
+ mcpServers: { ...b.mcpServers, ...a.mcpServers },
307
+ bins: [...binByName.values()],
308
+ skills: [...skillMap.values()],
309
+ settings: { ...b.settings, ...a.settings },
310
+ // Same precedence as settings: `a` wins. Whole-object, for the reason
311
+ // given in resolveExtends — half a routing config is not a routing config.
312
+ ...((a.models ?? b.models) ? { models: a.models ?? b.models } : {}),
313
+ env: {
314
+ required: [...new Set([...a.env.required, ...b.env.required])],
315
+ optional: [...new Set([...a.env.optional, ...b.env.optional])],
316
+ },
317
+ ...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Resolve the union of ALL profiles in a manifest — the closure `magus
323
+ * install` (no argument) materializes so every profile's deps are present.
324
+ */
325
+ export async function resolveAllProfiles(
326
+ manifest: ProfileManifest,
327
+ opts: ResolveOptions = {},
328
+ ): Promise<ResolvedClosure> {
329
+ const ids = Object.keys(manifest.profiles);
330
+ let union: ResolvedClosure = {
331
+ marketplaces: {},
332
+ plugins: {},
333
+ mcpServers: {},
334
+ bins: [],
335
+ skills: [],
336
+ settings: {},
337
+ env: { required: [], optional: [] },
338
+ };
339
+ for (const id of ids) {
340
+ const closure = await resolveProfile(manifest, id, opts);
341
+ union = mergeClosures(union, closure);
342
+ }
343
+ return union;
344
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Read/modify/write a Claude Code `settings.json`, in place.
3
+ *
4
+ * Two rules, and both exist because of damage they prevent:
5
+ *
6
+ * 1. **Write IN PLACE, never remove-and-recreate.** `.claude/settings.json` is
7
+ * a SYMLINK into `.claude/_profiles/<name>/` whenever a profile is active,
8
+ * and `writeFile` follows it — which is what we want. Unlinking the file
9
+ * first would break the link and silently detach the project from its
10
+ * profile, leaving the next read looking at a fresh, empty file.
11
+ * 2. **Unparseable is an ERROR, not an empty object.** A settings file that
12
+ * fails to parse is a file we do not understand, usually because a human is
13
+ * mid-edit. Treating it as `{}` and writing our one key back is how a
14
+ * user's whole configuration disappears. We refuse and say so.
15
+ *
16
+ * A MISSING file is different from an unparseable one: nothing is lost by
17
+ * starting from `{}`, so that is what a missing (or empty) file reads as.
18
+ */
19
+
20
+ import path from "node:path";
21
+ import fs from "fs-extra";
22
+
23
+ /**
24
+ * Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws.
25
+ *
26
+ * Exported for callers that only need the read half — the refusal wording is
27
+ * the same either way, so nothing has to reimplement it.
28
+ */
29
+ export async function readSettingsFile(
30
+ settingsPath: string,
31
+ ): Promise<Record<string, unknown>> {
32
+ if (!(await fs.pathExists(settingsPath))) return {};
33
+
34
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
35
+ if (!raw) return {};
36
+
37
+ try {
38
+ return JSON.parse(raw) as Record<string, unknown>;
39
+ } catch (error) {
40
+ throw new Error(
41
+ `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read, apply `mutate`, write back — preserving every key we did not touch.
48
+ *
49
+ * `mutate` may edit the object in place and return nothing, or return a
50
+ * replacement object. Nothing is written when the read throws, so a settings
51
+ * file we cannot parse is left exactly as the user left it.
52
+ */
53
+ export async function updateSettingsFile(
54
+ settingsPath: string,
55
+ // biome-ignore lint/suspicious/noConfusingVoidType: the union IS the contract — mutate in place and return nothing, or return a replacement
56
+ mutate: (settings: Record<string, unknown>) => Record<string, unknown> | void,
57
+ ): Promise<void> {
58
+ const settings = await readSettingsFile(settingsPath);
59
+ const next =
60
+ (mutate(settings) as Record<string, unknown> | undefined) ?? settings;
61
+
62
+ await fs.ensureDir(path.dirname(settingsPath));
63
+ // writeFile, never remove-then-create: see rule 1 above.
64
+ await fs.writeFile(
65
+ settingsPath,
66
+ `${JSON.stringify(next, null, 2)}\n`,
67
+ "utf8",
68
+ );
69
+ }