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.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildAdoptedProfile,
|
|
4
|
+
findMachineSpecificPaths,
|
|
5
|
+
} from "../services/profile-adopt.js";
|
|
6
|
+
|
|
7
|
+
const NOW = "2026-08-27T00:00:00.000Z";
|
|
8
|
+
|
|
9
|
+
describe("buildAdoptedProfile", () => {
|
|
10
|
+
test("adopts enabled plugins as 'latest', never as the installed version", () => {
|
|
11
|
+
// Pinning whatever a machine drifted to would turn one developer's
|
|
12
|
+
// accident into the team's contract.
|
|
13
|
+
const entry = buildAdoptedProfile({
|
|
14
|
+
enabledPlugins: { "dev@magus": true, "mnemex@magus": true },
|
|
15
|
+
settings: {},
|
|
16
|
+
mcpServers: {},
|
|
17
|
+
now: NOW,
|
|
18
|
+
});
|
|
19
|
+
expect(entry.plugins).toEqual({
|
|
20
|
+
"dev@magus": "latest",
|
|
21
|
+
"mnemex@magus": "latest",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("skips disabled plugins", () => {
|
|
26
|
+
const entry = buildAdoptedProfile({
|
|
27
|
+
enabledPlugins: { "dev@magus": true, "designer@magus": false },
|
|
28
|
+
settings: {},
|
|
29
|
+
mcpServers: {},
|
|
30
|
+
now: NOW,
|
|
31
|
+
});
|
|
32
|
+
expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("skips an id with no marketplace — it cannot be reinstalled elsewhere", () => {
|
|
36
|
+
const entry = buildAdoptedProfile({
|
|
37
|
+
enabledPlugins: { "dev@magus": true, "some-local-thing": true },
|
|
38
|
+
settings: {},
|
|
39
|
+
mcpServers: {},
|
|
40
|
+
now: NOW,
|
|
41
|
+
});
|
|
42
|
+
expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("derives the marketplace each plugin id names", () => {
|
|
46
|
+
const entry = buildAdoptedProfile({
|
|
47
|
+
enabledPlugins: { "dev@magus": true },
|
|
48
|
+
settings: {},
|
|
49
|
+
mcpServers: {},
|
|
50
|
+
now: NOW,
|
|
51
|
+
});
|
|
52
|
+
expect(entry.marketplaces?.magus).toEqual({
|
|
53
|
+
source: "github",
|
|
54
|
+
repo: "MadAppGang/magus",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("drops enabledPlugins from settings — the plugins map expresses it", () => {
|
|
59
|
+
// Carrying both would let them disagree; materialization regenerates
|
|
60
|
+
// enabledPlugins from the plugins map.
|
|
61
|
+
const entry = buildAdoptedProfile({
|
|
62
|
+
enabledPlugins: { "dev@magus": true },
|
|
63
|
+
settings: {
|
|
64
|
+
enabledPlugins: { "dev@magus": true },
|
|
65
|
+
enableAllProjectMcpServers: true,
|
|
66
|
+
},
|
|
67
|
+
mcpServers: {},
|
|
68
|
+
now: NOW,
|
|
69
|
+
});
|
|
70
|
+
expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("carries MCP servers over verbatim", () => {
|
|
74
|
+
const servers = {
|
|
75
|
+
"chrome-devtools": { command: "npx", args: ["-y", "x"] },
|
|
76
|
+
};
|
|
77
|
+
const entry = buildAdoptedProfile({
|
|
78
|
+
enabledPlugins: { "dev@magus": true },
|
|
79
|
+
settings: {},
|
|
80
|
+
mcpServers: servers,
|
|
81
|
+
now: NOW,
|
|
82
|
+
});
|
|
83
|
+
expect(entry.mcpServers).toEqual(servers);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("omits empty settings and mcpServers rather than writing empty objects", () => {
|
|
87
|
+
const entry = buildAdoptedProfile({
|
|
88
|
+
enabledPlugins: { "dev@magus": true },
|
|
89
|
+
settings: {},
|
|
90
|
+
mcpServers: {},
|
|
91
|
+
now: NOW,
|
|
92
|
+
});
|
|
93
|
+
expect(entry.settings).toBeUndefined();
|
|
94
|
+
expect(entry.mcpServers).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("strips installedPluginVersions — that is machine state, not a contract", () => {
|
|
98
|
+
// Committing it publishes one developer's install state as the team's
|
|
99
|
+
// contract, and it is stale the moment anyone updates.
|
|
100
|
+
const entry = buildAdoptedProfile({
|
|
101
|
+
enabledPlugins: { "dev@magus": true },
|
|
102
|
+
settings: {
|
|
103
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
104
|
+
enableAllProjectMcpServers: true,
|
|
105
|
+
},
|
|
106
|
+
mcpServers: {},
|
|
107
|
+
now: NOW,
|
|
108
|
+
});
|
|
109
|
+
expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("omits settings entirely when only machine keys were present", () => {
|
|
113
|
+
const entry = buildAdoptedProfile({
|
|
114
|
+
enabledPlugins: { "dev@magus": true },
|
|
115
|
+
settings: {
|
|
116
|
+
enabledPlugins: { "dev@magus": true },
|
|
117
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
118
|
+
},
|
|
119
|
+
mcpServers: {},
|
|
120
|
+
now: NOW,
|
|
121
|
+
});
|
|
122
|
+
expect(entry.settings).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not mutate the caller's settings object", () => {
|
|
126
|
+
const settings = {
|
|
127
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
128
|
+
other: 1,
|
|
129
|
+
};
|
|
130
|
+
buildAdoptedProfile({
|
|
131
|
+
enabledPlugins: { "dev@magus": true },
|
|
132
|
+
settings,
|
|
133
|
+
mcpServers: {},
|
|
134
|
+
now: NOW,
|
|
135
|
+
});
|
|
136
|
+
expect(settings.installedPluginVersions).toEqual({ "dev@magus": "4.6.1" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("is deterministic when the clock is injected", () => {
|
|
140
|
+
const input = {
|
|
141
|
+
enabledPlugins: { "dev@magus": true },
|
|
142
|
+
settings: {},
|
|
143
|
+
mcpServers: {},
|
|
144
|
+
now: NOW,
|
|
145
|
+
};
|
|
146
|
+
expect(buildAdoptedProfile(input)).toEqual(buildAdoptedProfile(input));
|
|
147
|
+
expect(buildAdoptedProfile(input).createdAt).toBe(NOW);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("findMachineSpecificPaths", () => {
|
|
152
|
+
const HOME = "/Users/someone";
|
|
153
|
+
|
|
154
|
+
test("flags a home-directory path nested in an array", () => {
|
|
155
|
+
// Not stripped — an additionalDirectories list is often genuinely wanted —
|
|
156
|
+
// but surfaced, because it will not resolve on a teammate's machine.
|
|
157
|
+
expect(
|
|
158
|
+
findMachineSpecificPaths(
|
|
159
|
+
{ permissions: { additionalDirectories: ["../sibling", `${HOME}/x`] } },
|
|
160
|
+
HOME,
|
|
161
|
+
),
|
|
162
|
+
).toEqual([`permissions.additionalDirectories[1] = ${HOME}/x`]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("relative paths are not flagged", () => {
|
|
166
|
+
expect(
|
|
167
|
+
findMachineSpecificPaths(
|
|
168
|
+
{ permissions: { additionalDirectories: ["../magus", "./local"] } },
|
|
169
|
+
HOME,
|
|
170
|
+
),
|
|
171
|
+
).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns nothing rather than guessing when HOME is unset", () => {
|
|
175
|
+
expect(findMachineSpecificPaths({ a: `${HOME}/x` }, "")).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { listProfiles, showProfile, switchProfile } from "../cli/profile.js";
|
|
7
|
+
import { activeProfile } from "../services/symlink-manager.js";
|
|
8
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
9
|
+
|
|
10
|
+
let logs: string[];
|
|
11
|
+
let errs: string[];
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
logs = [];
|
|
15
|
+
errs = [];
|
|
16
|
+
console.log = mock((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
17
|
+
console.error = mock((...a: unknown[]) => errs.push(a.join(" "))) as any;
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => mock.restore());
|
|
20
|
+
|
|
21
|
+
async function setupProject(
|
|
22
|
+
manifest: ProfileManifest,
|
|
23
|
+
materialize: string[] = [],
|
|
24
|
+
): Promise<string> {
|
|
25
|
+
const dir = await mkdtemp(join(tmpdir(), "profcmd-"));
|
|
26
|
+
await fs.ensureDir(join(dir, ".claude"));
|
|
27
|
+
await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
|
|
28
|
+
for (const name of materialize) {
|
|
29
|
+
const pd = join(dir, ".claude", "_profiles", name);
|
|
30
|
+
await fs.ensureDir(pd);
|
|
31
|
+
await fs.writeJson(join(pd, "settings.json"), { which: name });
|
|
32
|
+
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
33
|
+
}
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const twoProfiles: ProfileManifest = {
|
|
38
|
+
version: 2,
|
|
39
|
+
profiles: {
|
|
40
|
+
frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
|
|
41
|
+
backend: { name: "Backend", plugins: { "dev@magus": "2.9.0" } },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe("profile list", () => {
|
|
46
|
+
let dir: string;
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("lists profiles, marks (not installed) and the active one", async () => {
|
|
52
|
+
dir = await setupProject(twoProfiles, ["frontend"]);
|
|
53
|
+
await switchProfile("frontend", dir); // activate
|
|
54
|
+
logs = [];
|
|
55
|
+
const code = await listProfiles(dir);
|
|
56
|
+
expect(code).toBe(0);
|
|
57
|
+
const out = logs.join("\n");
|
|
58
|
+
expect(out).toContain("frontend");
|
|
59
|
+
expect(out).toContain("Backend");
|
|
60
|
+
expect(out).toContain("(not installed)"); // backend isn't materialized
|
|
61
|
+
expect(out).toContain("Active: frontend");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("profile show", () => {
|
|
66
|
+
let dir: string;
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("prints a resolved closure summary", async () => {
|
|
72
|
+
dir = await setupProject(twoProfiles);
|
|
73
|
+
const code = await showProfile("backend", dir);
|
|
74
|
+
expect(code).toBe(0);
|
|
75
|
+
expect(logs.join("\n")).toContain("dev@magus@2.9.0");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("unknown profile → exit 1", async () => {
|
|
79
|
+
dir = await setupProject(twoProfiles);
|
|
80
|
+
expect(await showProfile("ghost", dir)).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("profile switch", () => {
|
|
85
|
+
let dir: string;
|
|
86
|
+
afterEach(async () => {
|
|
87
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("switches when materialized and records the active profile", async () => {
|
|
91
|
+
dir = await setupProject(twoProfiles, ["frontend", "backend"]);
|
|
92
|
+
expect(await switchProfile("backend", dir)).toBe(0);
|
|
93
|
+
expect(await activeProfile(dir)).toBe("backend");
|
|
94
|
+
// gitignore got the _profiles entry
|
|
95
|
+
expect(await fs.readFile(join(dir, ".gitignore"), "utf8")).toContain(
|
|
96
|
+
".claude/_profiles/",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("refuses to switch to a profile that isn't installed", async () => {
|
|
101
|
+
dir = await setupProject(twoProfiles, ["frontend"]);
|
|
102
|
+
expect(await switchProfile("backend", dir)).toBe(1);
|
|
103
|
+
expect(errs.join("\n")).toContain("magus install backend");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("unknown profile → exit 1", async () => {
|
|
107
|
+
dir = await setupProject(twoProfiles);
|
|
108
|
+
expect(await switchProfile("ghost", dir)).toBe(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
7
|
+
import {
|
|
8
|
+
buildProfileMcp,
|
|
9
|
+
buildProfileSettings,
|
|
10
|
+
materializeProfile,
|
|
11
|
+
} from "../services/profile-materializer.js";
|
|
12
|
+
import { profileDir } from "../services/symlink-manager.js";
|
|
13
|
+
import type { ResolvedClosure } from "../types/index.js";
|
|
14
|
+
|
|
15
|
+
const ROUTING: ModelsConfig = {
|
|
16
|
+
version: 1,
|
|
17
|
+
preset: "test",
|
|
18
|
+
main: { model: "opus", effort: "medium" },
|
|
19
|
+
grades: {
|
|
20
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
21
|
+
normal: { model: "opus", effort: "medium" },
|
|
22
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
23
|
+
},
|
|
24
|
+
agents: { "dev:architect": "smart" },
|
|
25
|
+
fallback: "normal",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
29
|
+
return {
|
|
30
|
+
marketplaces: {},
|
|
31
|
+
plugins: {},
|
|
32
|
+
mcpServers: {},
|
|
33
|
+
bins: [],
|
|
34
|
+
skills: [],
|
|
35
|
+
settings: {},
|
|
36
|
+
env: { required: [], optional: [] },
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("buildProfileSettings", () => {
|
|
42
|
+
test("enables every resolved plugin and merges declared settings", () => {
|
|
43
|
+
const s = buildProfileSettings(
|
|
44
|
+
closure({
|
|
45
|
+
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
46
|
+
settings: { enableAllProjectMcpServers: true },
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
expect(s.enabledPlugins).toEqual({
|
|
50
|
+
"dev@magus": true,
|
|
51
|
+
"terminal@magus": true,
|
|
52
|
+
});
|
|
53
|
+
expect(s.enableAllProjectMcpServers).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Regression: `install` enables the manifest-wide union at USER scope, and
|
|
57
|
+
// Claude Code resolves enabledPlugins per plugin id — an id the project
|
|
58
|
+
// scope omits falls through to the user scope's `true`. A profile that
|
|
59
|
+
// merely omits another profile's plugins therefore never disables them, so
|
|
60
|
+
// `profile switch` silently becomes additive instead of exclusive.
|
|
61
|
+
test("explicitly disables union plugins the profile excludes", () => {
|
|
62
|
+
const s = buildProfileSettings(
|
|
63
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
64
|
+
["designer@magus", "terminal@magus", "madbench@magus"],
|
|
65
|
+
);
|
|
66
|
+
expect(s.enabledPlugins).toEqual({
|
|
67
|
+
"designer@magus": true,
|
|
68
|
+
"terminal@magus": false,
|
|
69
|
+
"madbench@magus": false,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a profile's own plugin wins over its union entry", () => {
|
|
74
|
+
const s = buildProfileSettings(
|
|
75
|
+
closure({ plugins: { "terminal@magus": "4.1.4" } }),
|
|
76
|
+
["terminal@magus"],
|
|
77
|
+
);
|
|
78
|
+
expect(s.enabledPlugins).toEqual({ "terminal@magus": true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no union given -> no false entries (unchanged legacy shape)", () => {
|
|
82
|
+
const s = buildProfileSettings(
|
|
83
|
+
closure({ plugins: { "dev@magus": "latest" } }),
|
|
84
|
+
);
|
|
85
|
+
expect(s.enabledPlugins).toEqual({ "dev@magus": true });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("buildProfileMcp", () => {
|
|
90
|
+
test("wraps the closure's mcpServers", () => {
|
|
91
|
+
const m = buildProfileMcp(
|
|
92
|
+
closure({ mcpServers: { "chrome-devtools": { command: "npx" } } }),
|
|
93
|
+
);
|
|
94
|
+
expect(m.mcpServers["chrome-devtools"]).toEqual({ command: "npx" });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("materializeProfile", () => {
|
|
99
|
+
let project: string;
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
project = await mkdtemp(join(tmpdir(), "materialize-"));
|
|
102
|
+
});
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await rm(project, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("writes settings.json, mcp.json, and an empty skills/ dir", async () => {
|
|
108
|
+
const dir = await materializeProfile(
|
|
109
|
+
"frontend",
|
|
110
|
+
closure({
|
|
111
|
+
plugins: { "dev@magus": "latest" },
|
|
112
|
+
mcpServers: { x: { command: "y" } },
|
|
113
|
+
}),
|
|
114
|
+
project,
|
|
115
|
+
);
|
|
116
|
+
expect(dir).toBe(profileDir("frontend", project));
|
|
117
|
+
expect(
|
|
118
|
+
(await fs.readJson(join(dir, "settings.json"))).enabledPlugins[
|
|
119
|
+
"dev@magus"
|
|
120
|
+
],
|
|
121
|
+
).toBe(true);
|
|
122
|
+
expect(
|
|
123
|
+
(await fs.readJson(join(dir, "mcp.json"))).mcpServers.x.command,
|
|
124
|
+
).toBe("y");
|
|
125
|
+
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("is idempotent (re-materialize overwrites cleanly)", async () => {
|
|
129
|
+
await materializeProfile(
|
|
130
|
+
"p",
|
|
131
|
+
closure({ plugins: { "a@m": "latest" } }),
|
|
132
|
+
project,
|
|
133
|
+
);
|
|
134
|
+
const dir = await materializeProfile(
|
|
135
|
+
"p",
|
|
136
|
+
closure({ plugins: { "b@m": "latest" } }),
|
|
137
|
+
project,
|
|
138
|
+
);
|
|
139
|
+
const settings = await fs.readJson(join(dir, "settings.json"));
|
|
140
|
+
expect(settings.enabledPlugins).toEqual({ "b@m": true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Regression: activating a profile replaces .claude/skills with a symlink,
|
|
144
|
+
// and replacing means fs.remove first. A repo that committed its own project
|
|
145
|
+
// skills before adopting profiles would lose them at that moment.
|
|
146
|
+
test("seeds skills/ from the project's pre-existing .claude/skills", async () => {
|
|
147
|
+
const src = join(project, ".claude", "skills", "systematic-debugging");
|
|
148
|
+
await fs.outputFile(join(src, "SKILL.md"), "# committed project skill\n");
|
|
149
|
+
|
|
150
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
151
|
+
|
|
152
|
+
expect(
|
|
153
|
+
await fs.readFile(
|
|
154
|
+
join(dir, "skills", "systematic-debugging", "SKILL.md"),
|
|
155
|
+
"utf8",
|
|
156
|
+
),
|
|
157
|
+
).toContain("committed project skill");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("does not re-seed skills/ once the profile dir exists", async () => {
|
|
161
|
+
await fs.outputFile(
|
|
162
|
+
join(project, ".claude", "skills", "gone", "SKILL.md"),
|
|
163
|
+
"# removed later\n",
|
|
164
|
+
);
|
|
165
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
166
|
+
await fs.remove(join(dir, "skills", "gone"));
|
|
167
|
+
|
|
168
|
+
await materializeProfile("p", closure(), project);
|
|
169
|
+
|
|
170
|
+
expect(await fs.pathExists(join(dir, "skills", "gone"))).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("still creates an empty skills/ when the project has none", async () => {
|
|
174
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
175
|
+
expect(await fs.pathExists(join(dir, "skills"))).toBe(true);
|
|
176
|
+
expect(await fs.readdir(join(dir, "skills"))).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("writes models.json when the closure carries routing", async () => {
|
|
180
|
+
const dir = await materializeProfile(
|
|
181
|
+
"p",
|
|
182
|
+
closure({ models: ROUTING }),
|
|
183
|
+
project,
|
|
184
|
+
);
|
|
185
|
+
expect(await fs.readJson(join(dir, "models.json"))).toEqual(ROUTING);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// The one artifact materialization DELETES. Every other file here is
|
|
189
|
+
// unconditional, so overwriting is enough; routing is optional, and a
|
|
190
|
+
// profile that dropped it would otherwise keep routing from a file nothing
|
|
191
|
+
// writes any more — `magus models off` could not turn routing off,
|
|
192
|
+
// because the hook walks up to the nearest models.json and finds this one.
|
|
193
|
+
test("deletes a stale models.json when the closure has no routing", async () => {
|
|
194
|
+
const dir = await materializeProfile(
|
|
195
|
+
"p",
|
|
196
|
+
closure({ models: ROUTING }),
|
|
197
|
+
project,
|
|
198
|
+
);
|
|
199
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(true);
|
|
200
|
+
|
|
201
|
+
await materializeProfile("p", closure(), project);
|
|
202
|
+
|
|
203
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("a profile that never had routing gets no models.json", async () => {
|
|
207
|
+
const dir = await materializeProfile("p", closure(), project);
|
|
208
|
+
expect(await fs.pathExists(join(dir, "models.json"))).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("two profiles from one union each disable the other's plugins", async () => {
|
|
212
|
+
const union = ["designer@magus", "terminal@magus"];
|
|
213
|
+
const fe = await materializeProfile(
|
|
214
|
+
"frontend",
|
|
215
|
+
closure({ plugins: { "designer@magus": "latest" } }),
|
|
216
|
+
project,
|
|
217
|
+
union,
|
|
218
|
+
);
|
|
219
|
+
const be = await materializeProfile(
|
|
220
|
+
"backend",
|
|
221
|
+
closure({ plugins: { "terminal@magus": "latest" } }),
|
|
222
|
+
project,
|
|
223
|
+
union,
|
|
224
|
+
);
|
|
225
|
+
expect(
|
|
226
|
+
(await fs.readJson(join(fe, "settings.json"))).enabledPlugins,
|
|
227
|
+
).toEqual({
|
|
228
|
+
"designer@magus": true,
|
|
229
|
+
"terminal@magus": false,
|
|
230
|
+
});
|
|
231
|
+
expect(
|
|
232
|
+
(await fs.readJson(join(be, "settings.json"))).enabledPlugins,
|
|
233
|
+
).toEqual({
|
|
234
|
+
"designer@magus": false,
|
|
235
|
+
"terminal@magus": true,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { computeSync, syncProfile } from "../services/profile-sync.js";
|
|
7
|
+
import { profileDir } from "../services/symlink-manager.js";
|
|
8
|
+
import type { ProfileManifestEntry } from "../types/index.js";
|
|
9
|
+
|
|
10
|
+
describe("computeSync (pure)", () => {
|
|
11
|
+
const entry: ProfileManifestEntry = {
|
|
12
|
+
name: "Frontend",
|
|
13
|
+
plugins: { "dev@magus": "2.9.0", "terminal@magus": "latest" },
|
|
14
|
+
settings: { effortLevel: "high" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test("adds a newly-enabled plugin, preserving existing pins", () => {
|
|
18
|
+
const { entry: next, changes } = computeSync(
|
|
19
|
+
entry,
|
|
20
|
+
{
|
|
21
|
+
enabledPlugins: {
|
|
22
|
+
"dev@magus": true,
|
|
23
|
+
"terminal@magus": true,
|
|
24
|
+
"designer@magus": true,
|
|
25
|
+
},
|
|
26
|
+
effortLevel: "high",
|
|
27
|
+
},
|
|
28
|
+
{ mcpServers: {} },
|
|
29
|
+
);
|
|
30
|
+
// existing pin preserved (not downgraded to latest)
|
|
31
|
+
expect(next.plugins!["dev@magus"]).toBe("2.9.0");
|
|
32
|
+
// new plugin added as latest
|
|
33
|
+
expect(next.plugins!["designer@magus"]).toBe("latest");
|
|
34
|
+
expect(changes).toContainEqual({
|
|
35
|
+
kind: "plugin-added",
|
|
36
|
+
detail: "designer@magus",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("removes a plugin that was disabled", () => {
|
|
41
|
+
const { entry: next, changes } = computeSync(
|
|
42
|
+
entry,
|
|
43
|
+
{ enabledPlugins: { "dev@magus": true }, effortLevel: "high" },
|
|
44
|
+
{ mcpServers: {} },
|
|
45
|
+
);
|
|
46
|
+
expect(next.plugins!["terminal@magus"]).toBeUndefined();
|
|
47
|
+
expect(changes).toContainEqual({
|
|
48
|
+
kind: "plugin-removed",
|
|
49
|
+
detail: "terminal@magus",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("captures a settings change and an mcp change", () => {
|
|
54
|
+
const { entry: next, changes } = computeSync(
|
|
55
|
+
entry,
|
|
56
|
+
{
|
|
57
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
58
|
+
effortLevel: "max",
|
|
59
|
+
},
|
|
60
|
+
{ mcpServers: { "chrome-devtools": { command: "npx" } } },
|
|
61
|
+
);
|
|
62
|
+
expect(next.settings!.effortLevel).toBe("max");
|
|
63
|
+
expect(next.mcpServers!["chrome-devtools"]).toEqual({ command: "npx" });
|
|
64
|
+
expect(changes.some((c) => c.kind === "settings-changed")).toBe(true);
|
|
65
|
+
expect(changes.some((c) => c.kind === "mcp-changed")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("no changes when the materialized state matches the manifest", () => {
|
|
69
|
+
const { changes } = computeSync(
|
|
70
|
+
entry,
|
|
71
|
+
{
|
|
72
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
73
|
+
effortLevel: "high",
|
|
74
|
+
},
|
|
75
|
+
{ mcpServers: {} },
|
|
76
|
+
);
|
|
77
|
+
expect(changes).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("refreshes updatedAt to the injected timestamp when changed", () => {
|
|
81
|
+
const withStamp: ProfileManifestEntry = { ...entry, updatedAt: "OLD" };
|
|
82
|
+
const { entry: changed } = computeSync(
|
|
83
|
+
withStamp,
|
|
84
|
+
{
|
|
85
|
+
enabledPlugins: {
|
|
86
|
+
"dev@magus": true,
|
|
87
|
+
"terminal@magus": true,
|
|
88
|
+
"new@magus": true,
|
|
89
|
+
},
|
|
90
|
+
effortLevel: "high",
|
|
91
|
+
},
|
|
92
|
+
{ mcpServers: {} },
|
|
93
|
+
"2026-07-27T00:00:00.000Z",
|
|
94
|
+
);
|
|
95
|
+
expect(changed.updatedAt).toBe("2026-07-27T00:00:00.000Z");
|
|
96
|
+
|
|
97
|
+
// Unchanged sync leaves updatedAt alone.
|
|
98
|
+
const { entry: same } = computeSync(
|
|
99
|
+
withStamp,
|
|
100
|
+
{
|
|
101
|
+
enabledPlugins: { "dev@magus": true, "terminal@magus": true },
|
|
102
|
+
effortLevel: "high",
|
|
103
|
+
},
|
|
104
|
+
{ mcpServers: {} },
|
|
105
|
+
"2026-07-27T00:00:00.000Z",
|
|
106
|
+
);
|
|
107
|
+
expect(same.updatedAt).toBe("OLD");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("syncProfile (io)", () => {
|
|
112
|
+
let dir: string;
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
dir = await mkdtemp(join(tmpdir(), "sync-"));
|
|
115
|
+
await fs.ensureDir(join(dir, ".claude"));
|
|
116
|
+
await fs.writeJson(join(dir, ".claude", "profiles.json"), {
|
|
117
|
+
version: 2,
|
|
118
|
+
profiles: {
|
|
119
|
+
frontend: { name: "Frontend", plugins: { "dev@magus": "2.9.0" } },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
afterEach(async () => {
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("promotes an edit in the materialized dir into the manifest file", async () => {
|
|
128
|
+
// A teammate enabled a new plugin directly in the active profile's settings.
|
|
129
|
+
const pd = profileDir("frontend", dir);
|
|
130
|
+
await fs.ensureDir(pd);
|
|
131
|
+
await fs.writeJson(join(pd, "settings.json"), {
|
|
132
|
+
enabledPlugins: { "dev@magus": true, "designer@magus": true },
|
|
133
|
+
});
|
|
134
|
+
await fs.writeJson(join(pd, "mcp.json"), { mcpServers: {} });
|
|
135
|
+
|
|
136
|
+
const { changes, wrote } = await syncProfile("frontend", dir);
|
|
137
|
+
expect(wrote).toBe(true);
|
|
138
|
+
expect(changes.some((c) => c.detail === "designer@magus")).toBe(true);
|
|
139
|
+
|
|
140
|
+
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
141
|
+
expect(manifest.profiles.frontend.plugins["designer@magus"]).toBe("latest");
|
|
142
|
+
expect(manifest.profiles.frontend.plugins["dev@magus"]).toBe("2.9.0"); // pin kept
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("no-op when nothing drifted", async () => {
|
|
146
|
+
const pd = profileDir("frontend", dir);
|
|
147
|
+
await fs.ensureDir(pd);
|
|
148
|
+
await fs.writeJson(join(pd, "settings.json"), {
|
|
149
|
+
enabledPlugins: { "dev@magus": true },
|
|
150
|
+
});
|
|
151
|
+
const { wrote } = await syncProfile("frontend", dir);
|
|
152
|
+
expect(wrote).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|