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,206 @@
1
+ /**
2
+ * Adopt a project into profile management.
3
+ *
4
+ * `install` and `update` both speak the language of `.claude/profiles.json`,
5
+ * and most repos do not have one. The answer is to write one from what the
6
+ * project already has — enabled plugins, MCP servers, settings — rather than
7
+ * to run a second, manifest-free code path beside it. One command shape, one
8
+ * source of truth, and the generated file is a reviewable diff the user can
9
+ * commit and hand to the rest of the team.
10
+ *
11
+ * Plugins are adopted as "latest", never as the version that happens to be
12
+ * installed today. Pinning what a machine drifted to would turn one developer's
13
+ * accident into the team's contract.
14
+ */
15
+
16
+ import type {
17
+ McpServerConfig,
18
+ ProfileManifest,
19
+ ProfileManifestEntry,
20
+ } from "../types/index.js";
21
+ import {
22
+ getEnabledPlugins,
23
+ getGlobalEnabledPlugins,
24
+ readMcpConfig,
25
+ readSettings,
26
+ } from "./claude-settings.js";
27
+ import { emptyManifest } from "./manifest.js";
28
+ import { withDerivedMarketplaces } from "./resolver.js";
29
+
30
+ /** The profile id used when adopting a project that has no manifest. */
31
+ export const ADOPTED_PROFILE_ID = "default";
32
+
33
+ export interface AdoptInput {
34
+ /** Union of enabled plugins across every scope: id -> enabled. */
35
+ enabledPlugins: Record<string, boolean>;
36
+ /** Project `.claude/settings.json`, verbatim. */
37
+ settings: Record<string, unknown>;
38
+ /** Project `.mcp.json` servers. */
39
+ mcpServers: Record<string, McpServerConfig>;
40
+ /** Display name for the generated profile. */
41
+ name?: string;
42
+ /** Injectable clock so the output is deterministic in tests. */
43
+ now?: string;
44
+ }
45
+
46
+ /**
47
+ * Build a manifest entry from captured project state. Pure.
48
+ *
49
+ * `enabledPlugins` is dropped from `settings` deliberately: it is expressed as
50
+ * the entry's `plugins` map, and materialization regenerates it. Carrying both
51
+ * would let them disagree.
52
+ */
53
+ /**
54
+ * Settings keys that describe THIS MACHINE, not the project, and must never be
55
+ * copied into a manifest meant to be committed.
56
+ *
57
+ * - `enabledPlugins` is expressed as the entry's `plugins` map and regenerated
58
+ * by materialization; carrying both lets them disagree.
59
+ * - `installedPluginVersions` is magus's private record of what this machine
60
+ * has on disk. Committing it publishes one developer's install state as if it
61
+ * were the team's contract, and it goes stale the moment anyone updates.
62
+ */
63
+ const MACHINE_ONLY_SETTINGS_KEYS = [
64
+ "enabledPlugins",
65
+ "installedPluginVersions",
66
+ ] as const;
67
+
68
+ /**
69
+ * Settings values that mention an absolute path under the user's home
70
+ * directory. Not stripped — a `permissions.additionalDirectories` list is often
71
+ * genuinely wanted — but surfaced so the user reviews them before committing.
72
+ */
73
+ export function findMachineSpecificPaths(
74
+ settings: Record<string, unknown>,
75
+ home: string = process.env.HOME ?? "",
76
+ ): string[] {
77
+ if (!home) return [];
78
+ const hits: string[] = [];
79
+ const walk = (value: unknown, path: string): void => {
80
+ if (typeof value === "string") {
81
+ if (value.includes(home)) hits.push(`${path} = ${value}`);
82
+ return;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ value.forEach((v, i) => walk(v, `${path}[${i}]`));
86
+ return;
87
+ }
88
+ if (value && typeof value === "object") {
89
+ for (const [k, v] of Object.entries(value)) walk(v, `${path}.${k}`);
90
+ }
91
+ };
92
+ for (const [k, v] of Object.entries(settings)) walk(v, k);
93
+ return hits;
94
+ }
95
+
96
+ export function buildAdoptedProfile(input: AdoptInput): ProfileManifestEntry {
97
+ const plugins: Record<string, string> = {};
98
+ for (const [id, enabled] of Object.entries(input.enabledPlugins)) {
99
+ // A plugin id must name its marketplace; anything else cannot be resolved
100
+ // or reinstalled on another machine, so it has no place in a team manifest.
101
+ if (enabled && id.includes("@")) plugins[id] = "latest";
102
+ }
103
+
104
+ const settings = { ...input.settings };
105
+ for (const key of MACHINE_ONLY_SETTINGS_KEYS) delete settings[key];
106
+
107
+ const entry: ProfileManifestEntry = {
108
+ name: input.name ?? "Default",
109
+ marketplaces: withDerivedMarketplaces(undefined, plugins),
110
+ plugins,
111
+ createdAt: input.now ?? new Date().toISOString(),
112
+ };
113
+ if (Object.keys(input.mcpServers).length > 0) {
114
+ entry.mcpServers = input.mcpServers;
115
+ }
116
+ if (Object.keys(settings).length > 0) entry.settings = settings;
117
+ return entry;
118
+ }
119
+
120
+ export interface AdoptResult {
121
+ manifest: ProfileManifest;
122
+ profileId: string;
123
+ entry: ProfileManifestEntry;
124
+ counts: { plugins: number; mcpServers: number; settingsKeys: number };
125
+ /** Which settings file the plugin list came from. */
126
+ source: AdoptSource;
127
+ /** Enabled plugin ids skipped because they name no marketplace. */
128
+ skipped: string[];
129
+ /** Carried-over settings values naming an absolute path under $HOME. */
130
+ machinePaths: string[];
131
+ }
132
+
133
+ /** Which settings file the adopted plugin list came from. */
134
+ export type AdoptSource = "project" | "user";
135
+
136
+ /**
137
+ * Find the plugins to adopt, preferring PROJECT scope.
138
+ *
139
+ * The generated manifest is meant to be committed, so its plugin list must
140
+ * describe the repo, not the machine. `.claude/settings.json` is exactly that:
141
+ * the repo's own committed choice. User scope (`~/.claude/settings.json`) is
142
+ * one developer's global set and local scope is gitignored personal state —
143
+ * adopting either silently would publish a personal setup as the team's
144
+ * contract.
145
+ *
146
+ * User scope is still offered as a fallback, because a developer who enables
147
+ * everything globally would otherwise have nothing to adopt. The caller is told
148
+ * which source was used and says so, so the choice is never invisible.
149
+ */
150
+ async function collectEnabledPlugins(
151
+ projectPath: string,
152
+ ): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
153
+ const project = await getEnabledPlugins(projectPath).catch(() => ({}));
154
+ if (Object.values(project).some(Boolean)) {
155
+ return { plugins: project, source: "project" };
156
+ }
157
+ const user = await getGlobalEnabledPlugins().catch(() => ({}));
158
+ return { plugins: user, source: "user" };
159
+ }
160
+
161
+ /**
162
+ * Read this project's current state and produce the manifest that describes it.
163
+ * Does not write — the caller confirms first.
164
+ */
165
+ export async function adoptCurrentProject(
166
+ projectPath: string,
167
+ name?: string,
168
+ ): Promise<AdoptResult> {
169
+ const { plugins: enabledPlugins, source } =
170
+ await collectEnabledPlugins(projectPath);
171
+ const settings = (await readSettings(projectPath)) as unknown as Record<
172
+ string,
173
+ unknown
174
+ >;
175
+ const mcp = await readMcpConfig(projectPath);
176
+
177
+ const entry = buildAdoptedProfile({
178
+ enabledPlugins,
179
+ settings,
180
+ mcpServers: mcp.mcpServers ?? {},
181
+ name,
182
+ });
183
+
184
+ const manifest = emptyManifest();
185
+ manifest.profiles[ADOPTED_PROFILE_ID] = entry;
186
+
187
+ const skipped = Object.entries(enabledPlugins)
188
+ .filter(([id, on]) => on && !id.includes("@"))
189
+ .map(([id]) => id);
190
+
191
+ return {
192
+ manifest,
193
+ profileId: ADOPTED_PROFILE_ID,
194
+ entry,
195
+ source,
196
+ counts: {
197
+ plugins: Object.keys(entry.plugins ?? {}).length,
198
+ mcpServers: Object.keys(entry.mcpServers ?? {}).length,
199
+ settingsKeys: Object.keys(entry.settings ?? {}).length,
200
+ },
201
+ skipped,
202
+ machinePaths: findMachineSpecificPaths(
203
+ (entry.settings ?? {}) as Record<string, unknown>,
204
+ ),
205
+ };
206
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Materialize a resolved profile into `.claude/_profiles/<name>/`.
3
+ *
4
+ * Writes the shared config the symlink manager later points at:
5
+ * _profiles/<name>/settings.json — enabledPlugins + the profile's settings
6
+ * _profiles/<name>/mcp.json — the profile's MCP servers
7
+ * _profiles/<name>/models.json — per-subagent model routing, when the
8
+ * profile declares any; REMOVED when it
9
+ * does not
10
+ * _profiles/<name>/skills/ — created empty; skills install into it
11
+ * (through the activated symlink)
12
+ *
13
+ * This is pure output derived from a ResolvedClosure — it performs no installs
14
+ * and touches no global state, so it is fully testable with a temp dir.
15
+ */
16
+
17
+ import path from "node:path";
18
+ import fs from "fs-extra";
19
+ import type { ResolvedClosure } from "../types/index.js";
20
+ import { profileDir } from "./symlink-manager.js";
21
+
22
+ /**
23
+ * Build the settings.json contents for a profile: every resolved plugin
24
+ * enabled, merged with the profile's declared settings. The profile's settings
25
+ * win over the derived enabledPlugins only if it explicitly sets that key
26
+ * (it normally won't).
27
+ *
28
+ * `allPluginIds` is the manifest-wide union of plugins across every profile.
29
+ * Plugins in the union that this profile does NOT want are written as an
30
+ * explicit `false`, which is what makes `profile switch` exclusive rather than
31
+ * additive. Claude Code resolves enabledPlugins **per plugin id**, walking from
32
+ * the highest-precedence scope down and taking the first scope that mentions
33
+ * the id — an id a scope omits falls through to the next one. Since
34
+ * `magus install` installs the union at user scope, every non-member would
35
+ * otherwise inherit that user-scope `true` and stay enabled while a profile
36
+ * that excludes it is active. Omitting the union (the default) preserves the
37
+ * old additive behavior for callers that have no cross-profile view.
38
+ */
39
+ export function buildProfileSettings(
40
+ closure: ResolvedClosure,
41
+ allPluginIds: readonly string[] = [],
42
+ ): Record<string, unknown> {
43
+ const enabledPlugins: Record<string, boolean> = {};
44
+ for (const pluginId of allPluginIds) {
45
+ enabledPlugins[pluginId] = false;
46
+ }
47
+ for (const pluginId of Object.keys(closure.plugins)) {
48
+ enabledPlugins[pluginId] = true;
49
+ }
50
+ return { enabledPlugins, ...closure.settings };
51
+ }
52
+
53
+ /** Build the .mcp.json contents for a profile. */
54
+ export function buildProfileMcp(closure: ResolvedClosure): {
55
+ mcpServers: Record<string, unknown>;
56
+ } {
57
+ return { mcpServers: { ...closure.mcpServers } };
58
+ }
59
+
60
+ /**
61
+ * Seed a profile's skills/ dir from the project's pre-existing `.claude/skills/`.
62
+ *
63
+ * Activating a profile replaces `.claude/skills` with a symlink into the profile
64
+ * dir, and replacing means `fs.remove` first. A project that committed its own
65
+ * skills before adopting profiles would lose them at that moment. So the first
66
+ * time a profile's skills/ dir is created, any real (non-symlink) project skills
67
+ * are copied in — every profile inherits what the project already had, and the
68
+ * link swap destroys nothing.
69
+ *
70
+ * Only runs when the profile's skills/ dir does not exist yet, so it never
71
+ * fights a later `skills-manager` install or re-adds a skill the user removed.
72
+ */
73
+ async function seedSkillsFromProject(
74
+ skillsDir: string,
75
+ projectPath?: string,
76
+ ): Promise<void> {
77
+ if (await fs.pathExists(skillsDir)) return;
78
+
79
+ const projectSkills = path.join(
80
+ projectPath ?? process.cwd(),
81
+ ".claude",
82
+ "skills",
83
+ );
84
+ try {
85
+ // lstat, not pathExists: an existing profile symlink is not a source.
86
+ const stat = await fs.lstat(projectSkills);
87
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
88
+ if ((await fs.readdir(projectSkills)).length === 0) return;
89
+ await fs.copy(projectSkills, skillsDir, { dereference: true });
90
+ } catch {
91
+ // No project skills dir (or unreadable) — nothing to carry over.
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Write `_profiles/<name>/` from a resolved closure. Idempotent: overwrites
97
+ * settings.json / mcp.json and ensures skills/ exists. Returns the dir path.
98
+ */
99
+ export async function materializeProfile(
100
+ name: string,
101
+ closure: ResolvedClosure,
102
+ projectPath?: string,
103
+ allPluginIds: readonly string[] = [],
104
+ ): Promise<string> {
105
+ const dir = profileDir(name, projectPath);
106
+ await fs.ensureDir(dir);
107
+ await fs.writeJson(
108
+ path.join(dir, "settings.json"),
109
+ buildProfileSettings(closure, allPluginIds),
110
+ { spaces: 2 },
111
+ );
112
+ await fs.writeJson(path.join(dir, "mcp.json"), buildProfileMcp(closure), {
113
+ spaces: 2,
114
+ });
115
+
116
+ // models.json is the one artifact that is REMOVED when the closure lacks it.
117
+ // Every other file here is unconditional, so "overwrite" is enough; routing
118
+ // is optional, and a profile that dropped it would otherwise keep routing
119
+ // from a file nothing writes any more — `magus models off` could not turn
120
+ // routing off, because the hook walks up to the nearest models.json and this
121
+ // one would still be there (and still linked).
122
+ const modelsPath = path.join(dir, "models.json");
123
+ if (closure.models) {
124
+ await fs.writeJson(modelsPath, closure.models, { spaces: 2 });
125
+ } else {
126
+ await fs.remove(modelsPath);
127
+ }
128
+
129
+ const skillsDir = path.join(dir, "skills");
130
+ await seedSkillsFromProject(skillsDir, projectPath);
131
+ await fs.ensureDir(skillsDir);
132
+ return dir;
133
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `profile sync` — promote edits made to the active profile dir back into the
3
+ * committed manifest.
4
+ *
5
+ * `.claude/_profiles/<name>/` is generated build output, so an edit to the
6
+ * symlinked settings.json / mcp.json would be lost on the next `install`. Sync
7
+ * reverse-materializes those files into `.claude/profiles.json` so the change
8
+ * becomes a reviewable manifest diff instead of silent, throwaway drift.
9
+ */
10
+
11
+ import path from "node:path";
12
+ import fs from "fs-extra";
13
+ import type {
14
+ McpServerConfig,
15
+ ProfileManifest,
16
+ ProfileManifestEntry,
17
+ } from "../types/index.js";
18
+ import { readManifest, writeManifest } from "./manifest.js";
19
+ import { profileDir } from "./symlink-manager.js";
20
+
21
+ export interface SyncChange {
22
+ kind: "plugin-added" | "plugin-removed" | "settings-changed" | "mcp-changed";
23
+ detail: string;
24
+ }
25
+
26
+ /**
27
+ * Merge a materialized profile's settings.json + mcp.json back into a manifest
28
+ * entry. Pure: returns the updated entry and the list of changes.
29
+ *
30
+ * - enabledPlugins present but not in the entry -> added as "latest".
31
+ * - entry plugins that are no longer enabled -> removed.
32
+ * - an existing version PIN is preserved (enabled != downgrade-to-latest).
33
+ * - non-enabledPlugins settings keys replace the entry's settings.
34
+ * - mcpServers replace the entry's mcpServers when they differ.
35
+ */
36
+ export function computeSync(
37
+ entry: ProfileManifestEntry,
38
+ materializedSettings: Record<string, unknown>,
39
+ materializedMcp: { mcpServers?: Record<string, McpServerConfig> },
40
+ now: string = new Date().toISOString(),
41
+ ): { entry: ProfileManifestEntry; changes: SyncChange[] } {
42
+ const changes: SyncChange[] = [];
43
+ const next: ProfileManifestEntry = { ...entry };
44
+
45
+ const enabled = (materializedSettings.enabledPlugins ?? {}) as Record<
46
+ string,
47
+ boolean
48
+ >;
49
+ const currentPlugins = { ...(entry.plugins ?? {}) };
50
+
51
+ // Additions: enabled plugins the manifest doesn't list.
52
+ for (const [id, on] of Object.entries(enabled)) {
53
+ if (on && !(id in currentPlugins)) {
54
+ currentPlugins[id] = "latest";
55
+ changes.push({ kind: "plugin-added", detail: id });
56
+ }
57
+ }
58
+ // Removals: manifest plugins no longer enabled.
59
+ for (const id of Object.keys(entry.plugins ?? {})) {
60
+ if (!enabled[id]) {
61
+ delete currentPlugins[id];
62
+ changes.push({ kind: "plugin-removed", detail: id });
63
+ }
64
+ }
65
+ next.plugins = currentPlugins;
66
+
67
+ // Settings = everything in the materialized file except enabledPlugins.
68
+ const { enabledPlugins: _drop, ...settings } = materializedSettings;
69
+ if (JSON.stringify(settings) !== JSON.stringify(entry.settings ?? {})) {
70
+ if (Object.keys(settings).length > 0) next.settings = settings;
71
+ else {
72
+ // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
73
+ delete next.settings;
74
+ }
75
+ changes.push({ kind: "settings-changed", detail: "settings updated" });
76
+ }
77
+
78
+ // MCP servers.
79
+ const mcp = materializedMcp.mcpServers ?? {};
80
+ if (JSON.stringify(mcp) !== JSON.stringify(entry.mcpServers ?? {})) {
81
+ if (Object.keys(mcp).length > 0) next.mcpServers = mcp;
82
+ else {
83
+ // biome-ignore lint/performance/noDelete: the manifest entry is serialised to profiles.json and diffed with JSON.stringify/toStrictEqual; the key must be absent, not undefined
84
+ delete next.mcpServers;
85
+ }
86
+ changes.push({ kind: "mcp-changed", detail: "mcp servers updated" });
87
+ }
88
+
89
+ // Refresh the timestamp when anything changed. `now` is injectable so
90
+ // callers/tests control it (and computeSync stays pure).
91
+ if (changes.length > 0) next.updatedAt = now;
92
+
93
+ return { entry: next, changes };
94
+ }
95
+
96
+ /**
97
+ * Detect drift between a materialized profile and the manifest WITHOUT writing.
98
+ * Returns the changes `sync` would promote (empty = in sync). Returns [] if the
99
+ * profile isn't materialized (nothing to lose).
100
+ */
101
+ export async function detectProfileDrift(
102
+ name: string,
103
+ projectPath?: string,
104
+ ): Promise<SyncChange[]> {
105
+ const manifest = await readManifest(projectPath);
106
+ const entry = manifest.profiles[name];
107
+ if (!entry) return [];
108
+
109
+ const dir = profileDir(name, projectPath);
110
+ const settingsPath = path.join(dir, "settings.json");
111
+ if (!(await fs.pathExists(settingsPath))) return [];
112
+
113
+ const mcpPath = path.join(dir, "mcp.json");
114
+ const materializedSettings = await fs.readJson(settingsPath);
115
+ const materializedMcp = (await fs.pathExists(mcpPath))
116
+ ? await fs.readJson(mcpPath)
117
+ : { mcpServers: {} };
118
+
119
+ return computeSync(entry, materializedSettings, materializedMcp).changes;
120
+ }
121
+
122
+ /**
123
+ * Read the materialized active profile, merge it into the manifest, and write
124
+ * the manifest back if anything changed. Returns the applied changes.
125
+ */
126
+ export async function syncProfile(
127
+ name: string,
128
+ projectPath?: string,
129
+ ): Promise<{ changes: SyncChange[]; wrote: boolean }> {
130
+ const manifest: ProfileManifest = await readManifest(projectPath);
131
+ const entry = manifest.profiles[name];
132
+ if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
133
+
134
+ const dir = profileDir(name, projectPath);
135
+ const settingsPath = path.join(dir, "settings.json");
136
+ const mcpPath = path.join(dir, "mcp.json");
137
+ if (!(await fs.pathExists(settingsPath))) {
138
+ throw new Error(
139
+ `Profile "${name}" isn't materialized (${settingsPath} missing).`,
140
+ );
141
+ }
142
+
143
+ const materializedSettings = await fs.readJson(settingsPath);
144
+ const materializedMcp = (await fs.pathExists(mcpPath))
145
+ ? await fs.readJson(mcpPath)
146
+ : { mcpServers: {} };
147
+
148
+ const { entry: nextEntry, changes } = computeSync(
149
+ entry,
150
+ materializedSettings,
151
+ materializedMcp,
152
+ );
153
+ if (changes.length === 0) return { changes: [], wrote: false };
154
+
155
+ manifest.profiles[name] = nextEntry;
156
+ await writeManifest(manifest, projectPath);
157
+ return { changes, wrote: true };
158
+ }