magus-cli 0.0.0-placeholder → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +725 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +545 -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 +524 -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 +248 -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 +340 -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__/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 +345 -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 +310 -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 +1396 -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 +403 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +528 -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 +264 -0
- package/src/cli/update-view.ts +717 -0
- package/src/cli/update.ts +600 -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 +1564 -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 +657 -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 +245 -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 +847 -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 +377 -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 +820 -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 +535 -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 +1423 -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 +440 -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 +149 -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,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
computeEnvStatus,
|
|
4
|
+
computeVersionDrift,
|
|
5
|
+
summarizeClosure,
|
|
6
|
+
} from "../services/install-plan.js";
|
|
7
|
+
import type {
|
|
8
|
+
InstalledPluginsRegistry,
|
|
9
|
+
ResolvedClosure,
|
|
10
|
+
} from "../types/index.js";
|
|
11
|
+
|
|
12
|
+
function closure(overrides: Partial<ResolvedClosure> = {}): ResolvedClosure {
|
|
13
|
+
return {
|
|
14
|
+
marketplaces: {},
|
|
15
|
+
plugins: {},
|
|
16
|
+
mcpServers: {},
|
|
17
|
+
bins: [],
|
|
18
|
+
skills: [],
|
|
19
|
+
settings: {},
|
|
20
|
+
env: { required: [], optional: [] },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function registry(plugins: Record<string, string[]>): InstalledPluginsRegistry {
|
|
26
|
+
const out: InstalledPluginsRegistry = { version: 2, plugins: {} };
|
|
27
|
+
for (const [id, versions] of Object.entries(plugins)) {
|
|
28
|
+
out.plugins[id] = versions.map((v) => ({
|
|
29
|
+
scope: "user",
|
|
30
|
+
installPath: `/cache/${id}/${v}`,
|
|
31
|
+
version: v,
|
|
32
|
+
installedAt: "t",
|
|
33
|
+
lastUpdated: "t",
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("computeVersionDrift", () => {
|
|
40
|
+
test("no drift when installed matches the pin", () => {
|
|
41
|
+
const c = closure({ plugins: { "dev@magus": "2.9.0" } });
|
|
42
|
+
expect(
|
|
43
|
+
computeVersionDrift(c, registry({ "dev@magus": ["2.9.0"] })),
|
|
44
|
+
).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("'latest' pins are never drift-checked", () => {
|
|
48
|
+
const c = closure({ plugins: { "dev@magus": "latest" } });
|
|
49
|
+
expect(
|
|
50
|
+
computeVersionDrift(c, registry({ "dev@magus": ["1.0.0"] })),
|
|
51
|
+
).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("flags a mismatch, reporting the highest installed version", () => {
|
|
55
|
+
const c = closure({ plugins: { "dev@magus": "2.9.0" } });
|
|
56
|
+
const drift = computeVersionDrift(
|
|
57
|
+
c,
|
|
58
|
+
registry({ "dev@magus": ["2.8.0", "2.10.0"] }),
|
|
59
|
+
);
|
|
60
|
+
expect(drift).toEqual([
|
|
61
|
+
{ pluginId: "dev@magus", pinned: "2.9.0", installed: "2.10.0" },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("flags a pinned plugin that isn't installed at all", () => {
|
|
66
|
+
const c = closure({ plugins: { "terminal@magus": "4.1.0" } });
|
|
67
|
+
expect(computeVersionDrift(c, registry({}))).toEqual([
|
|
68
|
+
{ pluginId: "terminal@magus", pinned: "4.1.0", installed: null },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("computeEnvStatus", () => {
|
|
74
|
+
test("splits present/missing required and optional", () => {
|
|
75
|
+
const c = closure({
|
|
76
|
+
env: { required: ["FIGMA_TOKEN", "SET_VAR"], optional: ["OPT"] },
|
|
77
|
+
});
|
|
78
|
+
const status = computeEnvStatus(c, { SET_VAR: "x", OPT: "" });
|
|
79
|
+
expect(status.missingRequired).toEqual(["FIGMA_TOKEN"]);
|
|
80
|
+
expect(status.required.find((e) => e.name === "SET_VAR")!.present).toBe(
|
|
81
|
+
true,
|
|
82
|
+
);
|
|
83
|
+
// empty string counts as missing
|
|
84
|
+
expect(status.optional[0]!.present).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("summarizeClosure", () => {
|
|
89
|
+
test("emits one labeled line per non-empty category", () => {
|
|
90
|
+
const c = closure({
|
|
91
|
+
marketplaces: { magus: { source: "github", repo: "MadAppGang/magus" } },
|
|
92
|
+
plugins: { "dev@magus": "latest", "terminal@magus": "4.1.0" },
|
|
93
|
+
bins: [
|
|
94
|
+
{ name: "claudish", via: "bun", version: "1.2.0", sources: ["x"] },
|
|
95
|
+
],
|
|
96
|
+
env: { required: ["FIGMA_TOKEN"], optional: [] },
|
|
97
|
+
});
|
|
98
|
+
const lines = summarizeClosure(c);
|
|
99
|
+
expect(lines.some((l) => l.startsWith("marketplaces"))).toBe(true);
|
|
100
|
+
expect(lines.some((l) => l.includes("terminal@magus@4.1.0"))).toBe(true);
|
|
101
|
+
expect(lines.some((l) => l.includes("claudish@1.2.0"))).toBe(true);
|
|
102
|
+
expect(lines.some((l) => l.startsWith("env"))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bin/magus.js` — what the launcher does when the prebuilt binary dies.
|
|
3
|
+
*
|
|
4
|
+
* The defect these tests pin: a process killed by a signal leaves
|
|
5
|
+
* `{status: null, signal: "SIGKILL", error: undefined}`. The launcher's guard
|
|
6
|
+
* read `error` only, so `status ?? 0` turned every signal death into exit 0.
|
|
7
|
+
* On macOS 27 an invalid code signature kills the binary at exec, so magus
|
|
8
|
+
* 4.40.0 through 6.8.0 printed nothing and reported success.
|
|
9
|
+
*
|
|
10
|
+
* The tests drive the real launcher inside a throwaway tree that mirrors the
|
|
11
|
+
* npm install layout:
|
|
12
|
+
*
|
|
13
|
+
* <tmp>/package.json ({"type":"module"})
|
|
14
|
+
* <tmp>/bin/magus.js (a copy of the real file)
|
|
15
|
+
* <tmp>/src/main.tsx (stub source path)
|
|
16
|
+
* <tmp>/node_modules/magus-cli-<plat>-<arch>/bin/magus (stub binary)
|
|
17
|
+
*
|
|
18
|
+
* `createRequire` inside the copy walks up from <tmp>/bin, so it can never
|
|
19
|
+
* reach this repo's own installed magus-cli-darwin-arm64. That is why the tree
|
|
20
|
+
* is built rather than NODE_PATH set: NODE_PATH is consulted only AFTER the
|
|
21
|
+
* node_modules walk fails, so a dev with the real optionalDependency installed
|
|
22
|
+
* would silently test the shipped binary instead of the stub.
|
|
23
|
+
*
|
|
24
|
+
* Each stub appends one line to $MAGUS_TEST_LEDGER. A ledger file gives
|
|
25
|
+
* ordering and argv forwarding, which an inherited stdio stream cannot. The
|
|
26
|
+
* source stub's exit code 7 is a value the masked path can never produce, so
|
|
27
|
+
* `status === 7` alone separates "fell back" from "pretended to succeed".
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import {
|
|
33
|
+
chmodSync,
|
|
34
|
+
cpSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
mkdtempSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
realpathSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import os from "node:os";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
|
|
46
|
+
const REAL_LAUNCHER = path.join(import.meta.dir, "..", "..", "bin", "magus.js");
|
|
47
|
+
const PKG_SUFFIX = `${process.platform}-${process.arch}`;
|
|
48
|
+
const PKG_NAME = `magus-cli-${PKG_SUFFIX}`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Absolute path to node, resolved once. The "Bun is not installed" tests hand
|
|
52
|
+
* the launcher a PATH with neither node nor bun on it, so the interpreter
|
|
53
|
+
* cannot be found by name.
|
|
54
|
+
*/
|
|
55
|
+
const NODE_BIN = spawnSync("sh", ["-c", "command -v node"], {
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
}).stdout.trim();
|
|
58
|
+
|
|
59
|
+
const dirs: string[] = [];
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
for (const dir of dirs.splice(0))
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function sourceStub(action: string): string {
|
|
67
|
+
return `import { appendFileSync } from "node:fs";
|
|
68
|
+
appendFileSync(
|
|
69
|
+
process.env.MAGUS_TEST_LEDGER as string,
|
|
70
|
+
\`SOURCE \${process.argv.slice(2).join(" ")}\\n\`,
|
|
71
|
+
);
|
|
72
|
+
${action}
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function makeTree(
|
|
77
|
+
opts: {
|
|
78
|
+
/** Shell the binary stub runs after logging. Omit = package absent. */
|
|
79
|
+
binaryAction?: string;
|
|
80
|
+
binaryMode?: number;
|
|
81
|
+
/** Statement the source stub runs after logging. */
|
|
82
|
+
sourceAction?: string;
|
|
83
|
+
} = {},
|
|
84
|
+
): string {
|
|
85
|
+
const dir = realpathSync(
|
|
86
|
+
mkdtempSync(path.join(os.tmpdir(), "magus-launcher-")),
|
|
87
|
+
);
|
|
88
|
+
dirs.push(dir);
|
|
89
|
+
|
|
90
|
+
// The launcher is ESM. Without a `type: module` package.json above it, node
|
|
91
|
+
// parses the copy as CJS and dies on the first `import`.
|
|
92
|
+
writeFileSync(
|
|
93
|
+
path.join(dir, "package.json"),
|
|
94
|
+
JSON.stringify({ name: "magus", version: "0.0.0", type: "module" }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
mkdirSync(path.join(dir, "bin"));
|
|
98
|
+
cpSync(REAL_LAUNCHER, path.join(dir, "bin", "magus.js"));
|
|
99
|
+
|
|
100
|
+
mkdirSync(path.join(dir, "src"));
|
|
101
|
+
writeFileSync(
|
|
102
|
+
path.join(dir, "src", "main.tsx"),
|
|
103
|
+
sourceStub(opts.sourceAction ?? "process.exit(7);"),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (opts.binaryAction !== undefined) {
|
|
107
|
+
const pkgDir = path.join(dir, "node_modules", PKG_NAME);
|
|
108
|
+
mkdirSync(path.join(pkgDir, "bin"), { recursive: true });
|
|
109
|
+
// No "exports" field: the launcher resolves `<pkg>/package.json`, which
|
|
110
|
+
// an exports map would refuse.
|
|
111
|
+
writeFileSync(
|
|
112
|
+
path.join(pkgDir, "package.json"),
|
|
113
|
+
JSON.stringify({ name: PKG_NAME, version: "0.0.0" }),
|
|
114
|
+
);
|
|
115
|
+
const bin = path.join(pkgDir, "bin", "magus");
|
|
116
|
+
writeFileSync(
|
|
117
|
+
bin,
|
|
118
|
+
`#!/bin/sh\necho "BINARY $*" >> "$MAGUS_TEST_LEDGER"\n${opts.binaryAction}\n`,
|
|
119
|
+
);
|
|
120
|
+
chmodSync(bin, opts.binaryMode ?? 0o755);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return dir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function binaryPathIn(dir: string): string {
|
|
127
|
+
return path.join(dir, "node_modules", PKG_NAME, "bin", "magus");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface Launch {
|
|
131
|
+
status: number | null;
|
|
132
|
+
signal: NodeJS.Signals | null;
|
|
133
|
+
stderr: string;
|
|
134
|
+
ledger: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function launch(
|
|
138
|
+
dir: string,
|
|
139
|
+
args: string[] = [],
|
|
140
|
+
env: Record<string, string> = {},
|
|
141
|
+
): Launch {
|
|
142
|
+
const ledgerPath = path.join(dir, "ledger.txt");
|
|
143
|
+
const result = spawnSync(
|
|
144
|
+
NODE_BIN,
|
|
145
|
+
[path.join(dir, "bin", "magus.js"), ...args],
|
|
146
|
+
{
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
// Nothing may reach a TUI renderer: hand the child an empty stdin.
|
|
149
|
+
input: "",
|
|
150
|
+
env: {
|
|
151
|
+
...process.env,
|
|
152
|
+
MAGUS_NO_BINARY: "",
|
|
153
|
+
MAGUS_TEST_LEDGER: ledgerPath,
|
|
154
|
+
...env,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
const ledger = existsSync(ledgerPath)
|
|
159
|
+
? readFileSync(ledgerPath, "utf8")
|
|
160
|
+
.split("\n")
|
|
161
|
+
.map((line) => line.trim())
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
: [];
|
|
164
|
+
return {
|
|
165
|
+
status: result.status,
|
|
166
|
+
signal: result.signal,
|
|
167
|
+
stderr: result.stderr ?? "",
|
|
168
|
+
ledger,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
describe("launcher: binary killed by a signal", () => {
|
|
173
|
+
test("T-sig-1: SIGKILL at exec falls back to source and explains why", () => {
|
|
174
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
175
|
+
const r = launch(dir, ["--version"]);
|
|
176
|
+
|
|
177
|
+
// The shipped bug in one line: this was 0.
|
|
178
|
+
expect(r.status).not.toBe(0);
|
|
179
|
+
// 7 is the stub source path's code — nothing else can produce it.
|
|
180
|
+
expect(r.status).toBe(7);
|
|
181
|
+
expect(r.ledger).toEqual(["BINARY --version", "SOURCE --version"]);
|
|
182
|
+
|
|
183
|
+
// A stranded user needs the signal, the file, and a way out.
|
|
184
|
+
expect(r.stderr).toContain("SIGKILL");
|
|
185
|
+
expect(r.stderr).toContain(binaryPathIn(dir));
|
|
186
|
+
expect(r.stderr).toContain("signature");
|
|
187
|
+
expect(r.stderr).toContain("codesign --verify");
|
|
188
|
+
expect(r.stderr).toContain("MAGUS_NO_BINARY=1");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("T-sig-2: SIGINT is the user's own Ctrl-C — exit 130, no fallback", () => {
|
|
192
|
+
const dir = makeTree({ binaryAction: "kill -s INT $$" });
|
|
193
|
+
const r = launch(dir);
|
|
194
|
+
|
|
195
|
+
expect(r.status).toBe(130);
|
|
196
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
197
|
+
expect(r.stderr).toBe("");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("T-sig-3: SIGTERM is exit 143, no fallback", () => {
|
|
201
|
+
const dir = makeTree({ binaryAction: "kill -s TERM $$" });
|
|
202
|
+
const r = launch(dir);
|
|
203
|
+
|
|
204
|
+
expect(r.status).toBe(143);
|
|
205
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("T-sig-4: SIGABRT is a crash, not a user signal — it must fall back", () => {
|
|
209
|
+
// The hole an allowlist of crash signals leaves: a Bun/JSC panic aborts,
|
|
210
|
+
// which is precisely the "binary is broken, use source" case.
|
|
211
|
+
const dir = makeTree({ binaryAction: "kill -s ABRT $$" });
|
|
212
|
+
const r = launch(dir);
|
|
213
|
+
|
|
214
|
+
expect(r.status).toBe(7);
|
|
215
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("T-sig-5: a kill seconds in is deliberate — report it, do not relaunch", () => {
|
|
219
|
+
// A signature kill lands in single-digit milliseconds at exec. A user
|
|
220
|
+
// running `kill -9` on a live TUI, or an OOM kill, lands much later.
|
|
221
|
+
// Without the time gate the fix trades a silent exit 0 for a surprise
|
|
222
|
+
// relaunch of an app the user deliberately killed.
|
|
223
|
+
const dir = makeTree({ binaryAction: "sleep 3\nkill -s KILL $$" });
|
|
224
|
+
const r = launch(dir);
|
|
225
|
+
|
|
226
|
+
expect(r.status).toBe(137);
|
|
227
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
228
|
+
}, 20000);
|
|
229
|
+
|
|
230
|
+
test("T-sig-6: an ordinary non-zero exit passes straight through", () => {
|
|
231
|
+
const dir = makeTree({ binaryAction: "exit 3" });
|
|
232
|
+
const r = launch(dir);
|
|
233
|
+
|
|
234
|
+
expect(r.status).toBe(3);
|
|
235
|
+
expect(r.ledger.some((line) => line.startsWith("SOURCE"))).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("T-sig-7: no platform package — straight to source, silently", () => {
|
|
239
|
+
const dir = makeTree();
|
|
240
|
+
const r = launch(dir);
|
|
241
|
+
|
|
242
|
+
expect(r.status).toBe(7);
|
|
243
|
+
expect(r.ledger).toEqual(["SOURCE"]);
|
|
244
|
+
expect(r.stderr).not.toContain("could not start");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("T-sig-8: an unexecutable binary still falls back and says so", () => {
|
|
248
|
+
const dir = makeTree({ binaryAction: "exit 0", binaryMode: 0o644 });
|
|
249
|
+
const r = launch(dir);
|
|
250
|
+
|
|
251
|
+
expect(r.status).toBe(7);
|
|
252
|
+
expect(r.stderr).toContain("could not start");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("T-sig-9: MAGUS_NO_BINARY=1 skips the binary entirely", () => {
|
|
256
|
+
// The escape hatch the SIGKILL diagnostic advertises has to work.
|
|
257
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
258
|
+
const r = launch(dir, [], { MAGUS_NO_BINARY: "1" });
|
|
259
|
+
|
|
260
|
+
expect(r.ledger.some((line) => line.startsWith("BINARY"))).toBe(false);
|
|
261
|
+
expect(r.status).toBe(7);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("T-sig-10: the source path has the same bug, and nowhere to fall back to", () => {
|
|
265
|
+
const dir = makeTree({
|
|
266
|
+
sourceAction: 'process.kill(process.pid, "SIGKILL");',
|
|
267
|
+
});
|
|
268
|
+
const r = launch(dir);
|
|
269
|
+
|
|
270
|
+
expect(r.status).toBe(137);
|
|
271
|
+
// No binary was involved, so a codesign hint here would be nonsense.
|
|
272
|
+
expect(r.stderr).not.toContain("codesign");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("T-sig-11: argv reaches both the binary and the fallback unchanged", () => {
|
|
276
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
277
|
+
const r = launch(dir, ["--version", "--x"]);
|
|
278
|
+
|
|
279
|
+
expect(r.ledger).toContain("BINARY --version --x");
|
|
280
|
+
expect(r.ledger).toContain("SOURCE --version --x");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// An empty PATH removes bun, so the source fallback cannot start either.
|
|
284
|
+
// This is the terminal state for the population the 6.8.1 fix exists for:
|
|
285
|
+
// magus installed from npm on macOS 27, no Bun on the machine.
|
|
286
|
+
const NO_BUN = { PATH: "" };
|
|
287
|
+
|
|
288
|
+
test("T-sig-12: binary crashed AND no Bun — the message must not deny the binary", () => {
|
|
289
|
+
const dir = makeTree({ binaryAction: "kill -s KILL $$" });
|
|
290
|
+
const r = launch(dir, ["--version"], NO_BUN);
|
|
291
|
+
|
|
292
|
+
expect(r.status).toBe(1);
|
|
293
|
+
// The contradiction: a binary was found, run, and reported killed
|
|
294
|
+
// moments earlier. Saying none exists sends the user down a dead end.
|
|
295
|
+
expect(r.stderr).not.toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
|
|
296
|
+
expect(r.stderr).toContain("unusable on this machine");
|
|
297
|
+
expect(r.stderr).toContain("npm install -g magus-cli@latest");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("T-sig-13: no binary AND no Bun — that message is still correct, and kept", () => {
|
|
301
|
+
const dir = makeTree();
|
|
302
|
+
const r = launch(dir, ["--version"], NO_BUN);
|
|
303
|
+
|
|
304
|
+
expect(r.status).toBe(1);
|
|
305
|
+
expect(r.stderr).toContain(`no prebuilt binary for ${PKG_SUFFIX}`);
|
|
306
|
+
expect(r.stderr).not.toContain("unusable on this machine");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import {
|
|
6
|
+
registeredMarketplaceNames,
|
|
7
|
+
scanMcpServers,
|
|
8
|
+
scanSkillDirs,
|
|
9
|
+
} from "../services/local-marketplace.js";
|
|
10
|
+
import { walkMarkdown, walkSkillDirs } from "../services/skills-manager.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The skill count in the plugin detail panel comes from a scan of the cloned
|
|
14
|
+
* marketplace. It used to read only the top level of `skills/`, which counts
|
|
15
|
+
* the *grouping* directories rather than the skills: dev reported 9 against 43
|
|
16
|
+
* on disk, mattpocock 5 against 37.
|
|
17
|
+
*/
|
|
18
|
+
describe("scanSkillDirs", () => {
|
|
19
|
+
let root: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-skills-"));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.remove(root);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
async function writeSkill(...segments: string[]): Promise<void> {
|
|
30
|
+
const dir = path.join(root, ...segments);
|
|
31
|
+
await fs.ensureDir(dir);
|
|
32
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), "---\nname: x\n---\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it("counts skills nested under grouping directories", async () => {
|
|
36
|
+
await writeSkill("skills", "frontend", "design-system");
|
|
37
|
+
await writeSkill("skills", "frontend", "forms");
|
|
38
|
+
await writeSkill("skills", "backend", "db-branching");
|
|
39
|
+
await writeSkill("skills", "flat-skill");
|
|
40
|
+
|
|
41
|
+
const found = await scanSkillDirs(path.join(root, "skills"));
|
|
42
|
+
|
|
43
|
+
expect(found.sort()).toEqual([
|
|
44
|
+
"db-branching",
|
|
45
|
+
"design-system",
|
|
46
|
+
"flat-skill",
|
|
47
|
+
"forms",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("never counts a grouping directory as a skill", async () => {
|
|
52
|
+
await writeSkill("skills", "engineering", "code-review");
|
|
53
|
+
await writeSkill("skills", "engineering", "tdd");
|
|
54
|
+
|
|
55
|
+
const found = await scanSkillDirs(path.join(root, "skills"));
|
|
56
|
+
|
|
57
|
+
expect(found.sort()).toEqual(["code-review", "tdd"]);
|
|
58
|
+
expect(found).not.toContain("engineering");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns nothing when the directory is absent", async () => {
|
|
62
|
+
expect(await scanSkillDirs(path.join(root, "nope"))).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("stops descending past four levels rather than walking the whole tree", async () => {
|
|
66
|
+
await writeSkill("skills", "a", "b", "c", "d", "too-deep");
|
|
67
|
+
|
|
68
|
+
expect(await scanSkillDirs(path.join(root, "skills"))).toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* mnemex ships one MCP server and nothing else. The scan only looked in an
|
|
74
|
+
* `mcp-servers/` directory, so it reported zero of every component kind and the
|
|
75
|
+
* plugin read as shipping nothing at all.
|
|
76
|
+
*/
|
|
77
|
+
describe("scanMcpServers", () => {
|
|
78
|
+
let root: string;
|
|
79
|
+
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-mcp-"));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
await fs.remove(root);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reads server names from a root .mcp.json", async () => {
|
|
89
|
+
await fs.writeJson(path.join(root, ".mcp.json"), {
|
|
90
|
+
mnemex: { command: "mnemex", args: ["--mcp"] },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(await scanMcpServers(root)).toEqual(["mnemex"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reads the mcpServers wrapper shape too", async () => {
|
|
97
|
+
await fs.writeJson(path.join(root, ".mcp.json"), {
|
|
98
|
+
mcpServers: { alpha: { command: "a" }, beta: { command: "b" } },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect((await scanMcpServers(root)).sort()).toEqual(["alpha", "beta"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("still reads an mcp-servers/ directory", async () => {
|
|
105
|
+
await fs.ensureDir(path.join(root, "mcp-servers"));
|
|
106
|
+
await fs.writeJson(path.join(root, "mcp-servers", "tmux.json"), {});
|
|
107
|
+
|
|
108
|
+
expect(await scanMcpServers(root)).toEqual(["tmux"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reports a server rather than zero when .mcp.json will not parse", async () => {
|
|
112
|
+
await fs.writeFile(path.join(root, ".mcp.json"), "{ not json");
|
|
113
|
+
|
|
114
|
+
expect(await scanMcpServers(root)).toEqual(["mcp"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reports none when the plugin declares no server", async () => {
|
|
118
|
+
expect(await scanMcpServers(root)).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The clone walkers behind the local-first read. A marketplace the user has
|
|
124
|
+
* added is on disk, so listing its plugins must cost no GitHub request — the
|
|
125
|
+
* hourly cap of 60 unauthenticated requests is shared across every repo on
|
|
126
|
+
* screen, and once it ran out every expand answered "Could not read the plugin",
|
|
127
|
+
* including for marketplaces sitting complete in the cache.
|
|
128
|
+
*/
|
|
129
|
+
describe("clone walkers", () => {
|
|
130
|
+
let root: string;
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "magus-walk-"));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await fs.remove(root);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
async function write(rel: string, body = "x"): Promise<void> {
|
|
141
|
+
const full = path.join(root, rel);
|
|
142
|
+
await fs.ensureDir(path.dirname(full));
|
|
143
|
+
await fs.writeFile(full, body);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("carries the grouping path so nested skills match the network shape", async () => {
|
|
147
|
+
await write("skills/frontend/design-system/SKILL.md");
|
|
148
|
+
await write("skills/backend/db/SKILL.md");
|
|
149
|
+
await write("skills/flat/SKILL.md");
|
|
150
|
+
|
|
151
|
+
const found = await walkSkillDirs(path.join(root, "skills"));
|
|
152
|
+
const byName = Object.fromEntries(found.map((f) => [f.name, f.group]));
|
|
153
|
+
|
|
154
|
+
expect(Object.keys(byName).sort()).toEqual(["db", "design-system", "flat"]);
|
|
155
|
+
expect(byName["design-system"]).toBe("frontend");
|
|
156
|
+
expect(byName.db).toBe("backend");
|
|
157
|
+
expect(byName.flat).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("lists markdown commands and skips README", async () => {
|
|
161
|
+
await write("commands/go.md");
|
|
162
|
+
await write("commands/README.md");
|
|
163
|
+
await write("commands/deep/trace.md");
|
|
164
|
+
|
|
165
|
+
const found = await walkMarkdown(path.join(root, "commands"));
|
|
166
|
+
|
|
167
|
+
expect(found.map((f) => f.name).sort()).toEqual(["go", "trace"]);
|
|
168
|
+
expect(found.find((f) => f.name === "trace")?.group).toBe("deep");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns nothing for a directory that is not there", async () => {
|
|
172
|
+
expect(await walkSkillDirs(path.join(root, "nope"))).toEqual([]);
|
|
173
|
+
expect(await walkMarkdown(path.join(root, "nope"))).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* REGRESSION: a directory under `marketplaces/` was treated as a marketplace.
|
|
179
|
+
*
|
|
180
|
+
* `scanLocalMarketplaces` enumerated the directory and accepted anything with a
|
|
181
|
+
* parseable manifest — never asking `known_marketplaces.json` whether it was
|
|
182
|
+
* registered. Claude Code refreshes a marketplace by delete-then-clone and
|
|
183
|
+
* leaves `<name>.bak` behind, so the reporter's machine carried a 13M
|
|
184
|
+
* `magus.bak` holding an older magus manifest. It cost them twice:
|
|
185
|
+
*
|
|
186
|
+
* - `getAllMarketplaces()` dedupes by repo, first-come, and `magus.bak`
|
|
187
|
+
* reported the same repo — so the REAL magus was dropped from the plugin
|
|
188
|
+
* list entirely. That is the "nothing installed and magus is not first"
|
|
189
|
+
* screenshot.
|
|
190
|
+
* - `findPluginInOtherMarketplace` then answered from it: `kanban@magus`
|
|
191
|
+
* rendered `moved → magus.bak`, pointing the user at a backup folder.
|
|
192
|
+
*
|
|
193
|
+
* `claude plugin marketplace list` never showed it — six entries, never seven —
|
|
194
|
+
* because Claude Code reads the registry. This is magus agreeing with it.
|
|
195
|
+
*/
|
|
196
|
+
describe("registeredMarketplaceNames", () => {
|
|
197
|
+
it("accepts a registry key", () => {
|
|
198
|
+
const names = registeredMarketplaceNames({
|
|
199
|
+
magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
|
|
200
|
+
});
|
|
201
|
+
expect(names?.has("magus")).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rejects a directory with no registry entry — the magus.bak case", () => {
|
|
205
|
+
const names = registeredMarketplaceNames({
|
|
206
|
+
magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
|
|
207
|
+
});
|
|
208
|
+
expect(names?.has("magus.bak")).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("also accepts the basename of installLocation when it differs from the key", () => {
|
|
212
|
+
// Nothing enforces key === directory name. Dropping a real marketplace is
|
|
213
|
+
// far worse than keeping a stray directory, so both spellings count.
|
|
214
|
+
const names = registeredMarketplaceNames({
|
|
215
|
+
magus: {
|
|
216
|
+
installLocation: "/h/.claude/plugins/marketplaces/magus-pinned",
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
expect(names?.has("magus")).toBe(true);
|
|
220
|
+
expect(names?.has("magus-pinned")).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null for input it cannot use, so nothing is filtered", () => {
|
|
224
|
+
// THE failure mode to avoid. Null means "no opinion" and the caller keeps
|
|
225
|
+
// every directory. Returning an empty set here instead would delete every
|
|
226
|
+
// marketplace on a corrupt registry, and every installed plugin would
|
|
227
|
+
// report as withdrawn — the exact "unknown rendered as a determinate
|
|
228
|
+
// negative" this whole change removes, rebuilt one level up.
|
|
229
|
+
expect(registeredMarketplaceNames(null)).toBeNull();
|
|
230
|
+
expect(registeredMarketplaceNames(undefined)).toBeNull();
|
|
231
|
+
expect(registeredMarketplaceNames("not an object")).toBeNull();
|
|
232
|
+
expect(registeredMarketplaceNames(["magus"])).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("treats an empty registry as an answer, not as missing data", () => {
|
|
236
|
+
// A file that exists and parses to {} says nothing is registered. An
|
|
237
|
+
// ABSENT file is what returns null, upstream in readKnownMarketplacesOrNull.
|
|
238
|
+
const names = registeredMarketplaceNames({});
|
|
239
|
+
expect(names).not.toBeNull();
|
|
240
|
+
expect(names?.size).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("survives an entry with no installLocation", () => {
|
|
244
|
+
const names = registeredMarketplaceNames({ magus: {} });
|
|
245
|
+
expect(names?.has("magus")).toBe(true);
|
|
246
|
+
expect(names?.size).toBe(1);
|
|
247
|
+
});
|
|
248
|
+
});
|