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,847 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
3
|
+
import type {
|
|
4
|
+
InstalledPluginsRegistry,
|
|
5
|
+
PluginRelease,
|
|
6
|
+
} from "../types/index.js";
|
|
7
|
+
import { formatMarketplaceName, parsePluginId } from "../utils/string-utils.js";
|
|
8
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
9
|
+
import {
|
|
10
|
+
getConfiguredMarketplaces,
|
|
11
|
+
getEnabledPlugins,
|
|
12
|
+
getGlobalClaudeDir,
|
|
13
|
+
getGlobalConfiguredMarketplaces,
|
|
14
|
+
getGlobalEnabledPlugins,
|
|
15
|
+
getGlobalInstalledPluginVersions,
|
|
16
|
+
getLocalEnabledPlugins,
|
|
17
|
+
getLocalInstalledPluginVersions,
|
|
18
|
+
getProjectInstalledPluginVersions,
|
|
19
|
+
pickRegistryEntry,
|
|
20
|
+
readInstalledPluginsRegistry,
|
|
21
|
+
readSettings,
|
|
22
|
+
removeFromInstalledPluginsRegistry,
|
|
23
|
+
saveGlobalInstalledPluginVersion,
|
|
24
|
+
saveLocalInstalledPluginVersion,
|
|
25
|
+
updateInstalledPluginsRegistry,
|
|
26
|
+
writeSettings,
|
|
27
|
+
} from "./claude-settings.js";
|
|
28
|
+
import { hasContentDrift } from "./content-drift.js";
|
|
29
|
+
import { inheritablePaths } from "./git-worktree.js";
|
|
30
|
+
import {
|
|
31
|
+
type LocalMarketplace,
|
|
32
|
+
type ProgressCallback,
|
|
33
|
+
type RepairMarketplaceResult,
|
|
34
|
+
repairAllMarketplaces,
|
|
35
|
+
scanLocalMarketplaces,
|
|
36
|
+
} from "./local-marketplace.js";
|
|
37
|
+
import {
|
|
38
|
+
type CatalogSource,
|
|
39
|
+
type MarketplaceFetchFailure,
|
|
40
|
+
type MarketplacePlugin,
|
|
41
|
+
clearMarketplaceCache as clearFetcherCache,
|
|
42
|
+
clearPersistedCatalogs,
|
|
43
|
+
fetchMarketplacePlugins,
|
|
44
|
+
getMarketplaceFetchFailures,
|
|
45
|
+
isAuthoritative,
|
|
46
|
+
resolveMarketplacePlugins,
|
|
47
|
+
} from "./marketplace-fetcher.js";
|
|
48
|
+
export {
|
|
49
|
+
fetchMarketplacePlugins,
|
|
50
|
+
getMarketplaceFetchFailures,
|
|
51
|
+
isAuthoritative,
|
|
52
|
+
resolveMarketplacePlugins,
|
|
53
|
+
type CatalogSource,
|
|
54
|
+
type MarketplaceFetchFailure,
|
|
55
|
+
type MarketplacePlugin,
|
|
56
|
+
};
|
|
57
|
+
// Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
|
|
58
|
+
let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
|
|
59
|
+
null;
|
|
60
|
+
|
|
61
|
+
export interface ScopeStatus {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
version?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PluginInfo {
|
|
67
|
+
id: string;
|
|
68
|
+
name: string;
|
|
69
|
+
version: string | null;
|
|
70
|
+
description: string;
|
|
71
|
+
marketplace: string;
|
|
72
|
+
marketplaceDisplay: string;
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
installedVersion?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Provably behind the catalog, on ONE version pair.
|
|
77
|
+
*
|
|
78
|
+
* @deprecated Derive plugin state from `classifyPlugin` (update-plan.ts),
|
|
79
|
+
* which answers per scope and distinguishes "cannot tell" from "current".
|
|
80
|
+
* Nothing in `src/` reads this any more — the content-drift gate was its
|
|
81
|
+
* last reader, and it now asks the version pair directly. It is still
|
|
82
|
+
* computed because `blackbox-parity.test.ts` reproduces the pre-classifier
|
|
83
|
+
* rule from it, which is what proves the fixture discriminates.
|
|
84
|
+
*
|
|
85
|
+
* Its sibling `updateUnknown` was written in six places and read in none,
|
|
86
|
+
* and is gone.
|
|
87
|
+
*/
|
|
88
|
+
hasUpdate?: boolean;
|
|
89
|
+
// Per-scope installation status
|
|
90
|
+
userScope?: ScopeStatus;
|
|
91
|
+
projectScope?: ScopeStatus;
|
|
92
|
+
localScope?: ScopeStatus;
|
|
93
|
+
// Extended info
|
|
94
|
+
category?: string;
|
|
95
|
+
author?: { name: string; email?: string };
|
|
96
|
+
homepage?: string;
|
|
97
|
+
tags?: string[];
|
|
98
|
+
/** Recent release history, newest first. Absent when the manifest publishes none. */
|
|
99
|
+
releases?: PluginRelease[];
|
|
100
|
+
agents?: string[];
|
|
101
|
+
commands?: string[];
|
|
102
|
+
skills?: string[];
|
|
103
|
+
mcpServers?: string[];
|
|
104
|
+
lspServers?: Record<string, unknown>;
|
|
105
|
+
/**
|
|
106
|
+
* The plugin's directory within its marketplace repo, as the manifest
|
|
107
|
+
* declares it ("./" for a repo that is one plugin, "./plugins/dev" for a
|
|
108
|
+
* multi-plugin marketplace). Needed to scope a skill listing to this
|
|
109
|
+
* plugin's subtree rather than the whole repo.
|
|
110
|
+
*/
|
|
111
|
+
source?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Set only when the plugin's files live in a repo other than its
|
|
114
|
+
* marketplace's — an object `source` naming a different repo.
|
|
115
|
+
*/
|
|
116
|
+
sourceRepo?: string;
|
|
117
|
+
isOrphaned?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Set when the installed version equals the catalog version, but the
|
|
120
|
+
* plugin's files in the marketplace have changed since the commit it was
|
|
121
|
+
* installed from. The version compare says "up to date" and is wrong: only
|
|
122
|
+
* a reinstall delivers the current content. See content-drift.ts.
|
|
123
|
+
*/
|
|
124
|
+
contentStale?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Set when this plugin's installed version changed since magus last
|
|
127
|
+
* rendered it — including updates made by Claude Code or a direct CLI call
|
|
128
|
+
* rather than by magus itself. Drives the "updated" badge.
|
|
129
|
+
*/
|
|
130
|
+
recentlyUpdatedFrom?: string;
|
|
131
|
+
/**
|
|
132
|
+
* Set when magus is seeing this plugin installed for the first time.
|
|
133
|
+
* Never set on the very first run, when there is no baseline to compare to.
|
|
134
|
+
*/
|
|
135
|
+
recentlyInstalled?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Which catalog supplied `version` — i.e. the number this plugin's installed
|
|
138
|
+
* version was compared against.
|
|
139
|
+
*
|
|
140
|
+
* `remote` (HTTP) and `remote-git` (a `git fetch`ed remote ref) are both
|
|
141
|
+
* authoritative — each is upstream's own catalog file. `local-clone` is the
|
|
142
|
+
* checked-out working tree, which is deliberately pinned by
|
|
143
|
+
* `autoUpdate: false`, so its versions can be arbitrarily old.
|
|
144
|
+
*/
|
|
145
|
+
catalogSource?: CatalogSource;
|
|
146
|
+
/**
|
|
147
|
+
* Set when `hasUpdate: false` could not actually be verified — the catalog
|
|
148
|
+
* fetch failed and the comparison ran against the pinned clone.
|
|
149
|
+
*
|
|
150
|
+
* This exists because the absence of an update was indistinguishable from a
|
|
151
|
+
* successful check. A rate-limited fetch returned `[]`, the stale clone filled
|
|
152
|
+
* in, its version compared equal to the installed one, and the UI reported
|
|
153
|
+
* "up to date" — the failure rendered as a clean bill of health. Anything that
|
|
154
|
+
* tells the user they are current must check this flag first.
|
|
155
|
+
*/
|
|
156
|
+
updateCheckFailed?: boolean;
|
|
157
|
+
/** Why the check failed, for display. Set with `updateCheckFailed`. */
|
|
158
|
+
updateCheckFailure?: MarketplaceFetchFailure;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* True when a plugin is enabled in some scope but no installed version resolves
|
|
163
|
+
* anywhere — it is listed in `enabledPlugins` but was never actually installed.
|
|
164
|
+
*
|
|
165
|
+
* This is a broken state, not a healthy one: Claude Code will not load the
|
|
166
|
+
* plugin, and because the update check is `installedVersion && compare(...)`,
|
|
167
|
+
* it is also invisible to every update path.
|
|
168
|
+
* It previously rendered identically to a working install.
|
|
169
|
+
*
|
|
170
|
+
* `isOrphaned` is a different failure — installed, but no longer offered by any
|
|
171
|
+
* marketplace — and is rendered as "deprecated" already.
|
|
172
|
+
*
|
|
173
|
+
* "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
|
|
174
|
+
* record exactly that), so it must NOT count as missing.
|
|
175
|
+
*/
|
|
176
|
+
export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
|
|
177
|
+
if (plugin.isOrphaned) return false;
|
|
178
|
+
const enabledSomewhere =
|
|
179
|
+
!!plugin.userScope?.enabled ||
|
|
180
|
+
!!plugin.projectScope?.enabled ||
|
|
181
|
+
!!plugin.localScope?.enabled;
|
|
182
|
+
return enabledSomewhere && !plugin.installedVersion;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* True when a scope genuinely has the plugin installed.
|
|
187
|
+
*
|
|
188
|
+
* `enabled` on its own is not proof. It is the `enabledPlugins` flag out of a
|
|
189
|
+
* settings file, and only magus and the user maintain it; `version` comes
|
|
190
|
+
* from `installed_plugins.json` via `overlayRegistryVersions`, which is what
|
|
191
|
+
* Claude Code actually loaded. When the two disagree — flag set, no registry
|
|
192
|
+
* entry — the plugin is in the broken enabled-but-not-installed state that the
|
|
193
|
+
* list already renders as "not installed".
|
|
194
|
+
*
|
|
195
|
+
* Every action that touches the filesystem must use this, not the flag alone.
|
|
196
|
+
* Deciding on the flag is what made the scope keys uninstall a plugin the row
|
|
197
|
+
* had just labelled "not installed".
|
|
198
|
+
*
|
|
199
|
+
* A version of "0.0.0" still counts as installed — Anthropic's official plugins
|
|
200
|
+
* record exactly that — which is why this tests presence, not `isKnownVersion`.
|
|
201
|
+
*/
|
|
202
|
+
export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
203
|
+
// Presence of files, NOT permission to load them. `enabled` was in this
|
|
204
|
+
// test until it was measured: a plugin with a real registry row that the
|
|
205
|
+
// user had simply switched off reported "not installed", so the row offered
|
|
206
|
+
// to install what was already on disk. Installation is a registry fact;
|
|
207
|
+
// enablement is a separate one, and `isEnabledButNotInstalled` is where the
|
|
208
|
+
// broken combination of the two is named.
|
|
209
|
+
return !!scope?.version;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One scope's status, from the two facts that define it.
|
|
214
|
+
*
|
|
215
|
+
* Exported and taken as arguments so this is testable and has exactly one
|
|
216
|
+
* implementation. It previously lived inline in `getAvailablePlugins` and its
|
|
217
|
+
* global twin — two copies, and both keyed existence off the settings flag:
|
|
218
|
+
*
|
|
219
|
+
* enabledPlugins[id] !== undefined ? {...} : undefined
|
|
220
|
+
*
|
|
221
|
+
* so a plugin with a registry row but no settings key produced NO scope at all
|
|
222
|
+
* and rendered exactly like one that was never installed. Existence is now
|
|
223
|
+
* "either fact is present", and `enabled` defaults to false rather than
|
|
224
|
+
* deciding whether the scope exists.
|
|
225
|
+
*/
|
|
226
|
+
export function buildScopeStatus(
|
|
227
|
+
enabledPlugins: Record<string, boolean>,
|
|
228
|
+
installedVersions: Record<string, string>,
|
|
229
|
+
pluginId: string,
|
|
230
|
+
): ScopeStatus | undefined {
|
|
231
|
+
const enabled = enabledPlugins[pluginId];
|
|
232
|
+
const version = installedVersions[pluginId];
|
|
233
|
+
if (enabled === undefined && version === undefined) return undefined;
|
|
234
|
+
return { enabled: enabled === true, version };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
|
|
239
|
+
*
|
|
240
|
+
* `install` doubles as the repair for enabled-but-not-installed. Measured
|
|
241
|
+
* against Claude Code 2.1.223 in exactly that state — `dev@magus` flagged in
|
|
242
|
+
* `.claude/settings.json` with no registry entry for the project path —
|
|
243
|
+
* `claude plugin install dev@magus --scope project` installed it normally and
|
|
244
|
+
* wrote the registry entry. It does not report "already installed", so this
|
|
245
|
+
* needs no uninstall-first dance (unlike content drift, see repairPlugin).
|
|
246
|
+
*
|
|
247
|
+
* `behind` is the answer to "is this scope provably behind?", and it comes from
|
|
248
|
+
* the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
|
|
249
|
+
* comparison made here. This used to take the catalog version and compare raw
|
|
250
|
+
* strings — a fourth "is it behind?" rule, which offered `update` for a scope
|
|
251
|
+
* AHEAD of the catalog or one whose version cannot be ordered at all, while the
|
|
252
|
+
* row beside it said something else.
|
|
253
|
+
*/
|
|
254
|
+
export function resolveScopeAction(
|
|
255
|
+
scope: ScopeStatus | undefined,
|
|
256
|
+
behind: boolean,
|
|
257
|
+
): "install" | "update" | "uninstall" {
|
|
258
|
+
// `enabled` is deliberately part of THIS decision even though it is no longer
|
|
259
|
+
// part of `isInstalledInScope`. The two ask different questions:
|
|
260
|
+
// "is it on disk" (installation) versus "what should this key do next".
|
|
261
|
+
//
|
|
262
|
+
// Installed-but-disabled must NOT resolve to uninstall. The scope key runs
|
|
263
|
+
// `cliUninstallPlugin` with no confirmation, so that combination would delete
|
|
264
|
+
// a plugin the user had merely switched off — with the row beside it showing
|
|
265
|
+
// no scope square, which reads as "not installed" and invites the press.
|
|
266
|
+
// `install` re-installs and re-enables, which is what the key meant before
|
|
267
|
+
// installation and enablement were separated, and is the only non-destructive
|
|
268
|
+
// answer here.
|
|
269
|
+
if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
|
|
270
|
+
return behind ? "update" : "uninstall";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function getAvailablePlugins(
|
|
274
|
+
projectPath?: string,
|
|
275
|
+
): Promise<PluginInfo[]> {
|
|
276
|
+
const configuredMarketplaces = await getConfiguredMarketplaces(projectPath);
|
|
277
|
+
const enabledPlugins = await getEnabledPlugins(projectPath);
|
|
278
|
+
const installedVersions = await getInstalledPluginVersions(projectPath);
|
|
279
|
+
|
|
280
|
+
// Fetch all scopes for per-scope status
|
|
281
|
+
const userEnabledPlugins = await getGlobalEnabledPlugins();
|
|
282
|
+
const userInstalledVersions = await getGlobalInstalledPluginVersions();
|
|
283
|
+
const projectEnabledPlugins = await getEnabledPlugins(projectPath);
|
|
284
|
+
const projectInstalledVersions =
|
|
285
|
+
await getInstalledPluginVersions(projectPath);
|
|
286
|
+
const localEnabledPlugins = await getLocalEnabledPlugins(projectPath);
|
|
287
|
+
const localInstalledVersions =
|
|
288
|
+
await getLocalInstalledPluginVersions(projectPath);
|
|
289
|
+
|
|
290
|
+
const plugins: PluginInfo[] = [];
|
|
291
|
+
const seenPluginIds = new Set<string>();
|
|
292
|
+
|
|
293
|
+
// Helper to build scope status
|
|
294
|
+
// One implementation, shared with the global view and unit-tested. Both
|
|
295
|
+
// copies used to key scope existence off the settings flag, so an installed
|
|
296
|
+
// plugin with no settings key produced no scope at all.
|
|
297
|
+
const scopeStatusFor = (pluginId: string) => ({
|
|
298
|
+
userScope: buildScopeStatus(
|
|
299
|
+
userEnabledPlugins,
|
|
300
|
+
userInstalledVersions,
|
|
301
|
+
pluginId,
|
|
302
|
+
),
|
|
303
|
+
projectScope: buildScopeStatus(
|
|
304
|
+
projectEnabledPlugins,
|
|
305
|
+
projectInstalledVersions,
|
|
306
|
+
pluginId,
|
|
307
|
+
),
|
|
308
|
+
localScope: buildScopeStatus(
|
|
309
|
+
localEnabledPlugins,
|
|
310
|
+
localInstalledVersions,
|
|
311
|
+
pluginId,
|
|
312
|
+
),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Get all marketplace names (configured + official/featured defaults)
|
|
316
|
+
// Always include official and featured marketplaces so users can browse them
|
|
317
|
+
const marketplaceNames = new Set<string>();
|
|
318
|
+
for (const mp of defaultMarketplaces) {
|
|
319
|
+
if (
|
|
320
|
+
configuredMarketplaces[mp.name] ||
|
|
321
|
+
mp.official ||
|
|
322
|
+
mp.featured ||
|
|
323
|
+
mp.owned
|
|
324
|
+
) {
|
|
325
|
+
marketplaceNames.add(mp.name);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Fetch local marketplace caches up front so we can detect stale caches
|
|
330
|
+
let localMarketplaces = await getLocalMarketplaces();
|
|
331
|
+
|
|
332
|
+
// Resolve every marketplace concurrently. These are independent HTTP calls
|
|
333
|
+
// with a 10s timeout each; awaited in sequence, one unreachable marketplace
|
|
334
|
+
// added its full timeout to the "Loading..." the user stares at. Measured on
|
|
335
|
+
// six marketplaces with four unreachable: 44.9s sequential.
|
|
336
|
+
const resolutions = await Promise.all(
|
|
337
|
+
[...marketplaceNames].map(async (mpName) => {
|
|
338
|
+
const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
|
|
339
|
+
if (!marketplace) return null;
|
|
340
|
+
return {
|
|
341
|
+
mpName,
|
|
342
|
+
marketplace,
|
|
343
|
+
resolution: await resolveMarketplacePlugins(
|
|
344
|
+
mpName,
|
|
345
|
+
marketplace.source.repo,
|
|
346
|
+
localMarketplaces,
|
|
347
|
+
),
|
|
348
|
+
};
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
for (const entry of resolutions) {
|
|
353
|
+
if (!entry) continue;
|
|
354
|
+
const { mpName, marketplace, resolution } = entry;
|
|
355
|
+
const marketplacePlugins = resolution.plugins;
|
|
356
|
+
|
|
357
|
+
// Auto-sync local cache if remote has plugins the local cache doesn't
|
|
358
|
+
localMarketplaces = await autoSyncIfStale(
|
|
359
|
+
mpName,
|
|
360
|
+
marketplacePlugins.map((p) => p.name),
|
|
361
|
+
localMarketplaces,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// The catalog that answered was not the authoritative one, so every
|
|
365
|
+
// version compare below is a guess, not a check.
|
|
366
|
+
// `remote-git` counts as verified: it is upstream's own catalog file, read
|
|
367
|
+
// through a git remote ref rather than over HTTP. Only the pinned working
|
|
368
|
+
// tree (`local-clone`) is untrustworthy for versions.
|
|
369
|
+
const unverified = !isAuthoritative(resolution.source);
|
|
370
|
+
|
|
371
|
+
for (const plugin of marketplacePlugins) {
|
|
372
|
+
const pluginId = `${plugin.name}@${mpName}`;
|
|
373
|
+
const installedVersion = installedVersions[pluginId];
|
|
374
|
+
const isEnabled = enabledPlugins[pluginId] === true;
|
|
375
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
376
|
+
|
|
377
|
+
seenPluginIds.add(pluginId);
|
|
378
|
+
plugins.push({
|
|
379
|
+
id: pluginId,
|
|
380
|
+
name: plugin.name,
|
|
381
|
+
version: plugin.version,
|
|
382
|
+
description: plugin.description,
|
|
383
|
+
marketplace: mpName,
|
|
384
|
+
marketplaceDisplay: marketplace.displayName,
|
|
385
|
+
enabled: isEnabled,
|
|
386
|
+
installedVersion: installedVersion,
|
|
387
|
+
hasUpdate: isBehind(plugin.version, installedVersion) === true,
|
|
388
|
+
catalogSource: resolution.source,
|
|
389
|
+
updateCheckFailed: unverified,
|
|
390
|
+
updateCheckFailure: resolution.failure,
|
|
391
|
+
...scopeStatus,
|
|
392
|
+
category: plugin.category,
|
|
393
|
+
author: plugin.author,
|
|
394
|
+
homepage: plugin.homepage,
|
|
395
|
+
tags: plugin.tags,
|
|
396
|
+
releases: plugin.releases,
|
|
397
|
+
source: plugin.source,
|
|
398
|
+
sourceRepo: plugin.sourceRepo,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const [mpName, localMp] of localMarketplaces) {
|
|
404
|
+
// Skip if already fetched from defaults
|
|
405
|
+
if (marketplaceNames.has(mpName)) continue;
|
|
406
|
+
|
|
407
|
+
// Add ALL plugins from this local marketplace cache
|
|
408
|
+
for (const localPlugin of localMp.plugins) {
|
|
409
|
+
const pluginId = `${localPlugin.name}@${mpName}`;
|
|
410
|
+
if (seenPluginIds.has(pluginId)) continue;
|
|
411
|
+
|
|
412
|
+
const installedVersion = installedVersions[pluginId];
|
|
413
|
+
const isEnabled = enabledPlugins[pluginId] === true;
|
|
414
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
415
|
+
|
|
416
|
+
seenPluginIds.add(pluginId);
|
|
417
|
+
plugins.push({
|
|
418
|
+
id: pluginId,
|
|
419
|
+
name: localPlugin.name,
|
|
420
|
+
version: localPlugin.version,
|
|
421
|
+
description: localPlugin.description || "",
|
|
422
|
+
marketplace: mpName,
|
|
423
|
+
marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
|
|
424
|
+
enabled: isEnabled,
|
|
425
|
+
installedVersion: installedVersion,
|
|
426
|
+
hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
|
|
427
|
+
...scopeStatus,
|
|
428
|
+
category: localPlugin.category,
|
|
429
|
+
author: localPlugin.author,
|
|
430
|
+
agents: localPlugin.agents,
|
|
431
|
+
commands: localPlugin.commands,
|
|
432
|
+
skills: localPlugin.skills,
|
|
433
|
+
source: localPlugin.source,
|
|
434
|
+
mcpServers: localPlugin.mcpServers,
|
|
435
|
+
lspServers: localPlugin.lspServers,
|
|
436
|
+
releases: localPlugin.releases,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Add orphaned plugins (enabled/installed but not in any cache)
|
|
442
|
+
const allPluginIds = new Set([
|
|
443
|
+
...Object.keys(enabledPlugins),
|
|
444
|
+
...Object.keys(installedVersions),
|
|
445
|
+
]);
|
|
446
|
+
|
|
447
|
+
for (const pluginId of allPluginIds) {
|
|
448
|
+
if (seenPluginIds.has(pluginId)) continue;
|
|
449
|
+
|
|
450
|
+
const parsed = parsePluginId(pluginId);
|
|
451
|
+
if (!parsed) continue;
|
|
452
|
+
|
|
453
|
+
const { pluginName, marketplace: mpName } = parsed;
|
|
454
|
+
const installedVersion = installedVersions[pluginId];
|
|
455
|
+
const isEnabled = enabledPlugins[pluginId] === true;
|
|
456
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
457
|
+
|
|
458
|
+
// Try to get plugin info from local marketplace cache (fallback)
|
|
459
|
+
const localMp = localMarketplaces.get(mpName);
|
|
460
|
+
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
461
|
+
|
|
462
|
+
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
463
|
+
const description = localPlugin?.description || "Installed plugin";
|
|
464
|
+
const hasUpdate = isBehind(localPlugin?.version, installedVersion) === true;
|
|
465
|
+
|
|
466
|
+
plugins.push({
|
|
467
|
+
id: pluginId,
|
|
468
|
+
name: pluginName,
|
|
469
|
+
version: latestVersion,
|
|
470
|
+
description,
|
|
471
|
+
marketplace: mpName,
|
|
472
|
+
marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
|
|
473
|
+
enabled: isEnabled,
|
|
474
|
+
installedVersion: installedVersion,
|
|
475
|
+
hasUpdate,
|
|
476
|
+
isOrphaned: true,
|
|
477
|
+
...scopeStatus,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
await annotateContentDrift(plugins, "project", projectPath);
|
|
482
|
+
return plugins;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Is this plugin one whose files could have moved while its version did not?
|
|
487
|
+
*
|
|
488
|
+
* Drift is defined as "same version, different files", so it is only a
|
|
489
|
+
* question at all when the catalog version and the installed version are
|
|
490
|
+
* PROVABLY the same — `compareVersions` returning exactly 0, never null, which
|
|
491
|
+
* would be "cannot tell". A pending version bump is not drift: `hasContentDrift`
|
|
492
|
+
* compares the installed commit's subtree against the marketplace clone's HEAD,
|
|
493
|
+
* and for a plugin that is simply behind, that subtree differs BECAUSE of the
|
|
494
|
+
* newer version. Flagging it would turn an `update` into a `repair`, and a
|
|
495
|
+
* repair reinstalls whatever the clone holds — past a pin.
|
|
496
|
+
*
|
|
497
|
+
* This used to open with `if (plugin.hasUpdate || …)`, the pre-classifier
|
|
498
|
+
* boolean. It decided nothing the version compare below does not (`isBehind`
|
|
499
|
+
* cannot be true for a pair that compares 0), and gating the input the shared
|
|
500
|
+
* classifier reads on the rule it replaced is how the two drift apart again.
|
|
501
|
+
* An orphan is still excluded: it has no marketplace entry to diff against.
|
|
502
|
+
*
|
|
503
|
+
* Exported to be tested directly; `annotateContentDrift` itself needs the real
|
|
504
|
+
* registry.
|
|
505
|
+
*/
|
|
506
|
+
export function contentDriftCandidate(plugin: PluginInfo): boolean {
|
|
507
|
+
if (plugin.isOrphaned) return false;
|
|
508
|
+
if (!plugin.installedVersion || !plugin.version) return false;
|
|
509
|
+
return compareVersions(plugin.version, plugin.installedVersion) === 0;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Flag plugins whose files moved without their version moving.
|
|
514
|
+
*
|
|
515
|
+
* Runs after the plugin list is assembled so every code path that builds a
|
|
516
|
+
* PluginInfo gets the same treatment. Which plugins are worth asking about is
|
|
517
|
+
* {@link contentDriftCandidate}.
|
|
518
|
+
*
|
|
519
|
+
* Best-effort throughout: a plugin whose drift cannot be determined is left
|
|
520
|
+
* exactly as it is today.
|
|
521
|
+
*/
|
|
522
|
+
async function annotateContentDrift(
|
|
523
|
+
plugins: PluginInfo[],
|
|
524
|
+
scope: "user" | "project" | "local",
|
|
525
|
+
projectPath?: string,
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
let registry: InstalledPluginsRegistry;
|
|
528
|
+
try {
|
|
529
|
+
registry = await readInstalledPluginsRegistry();
|
|
530
|
+
} catch {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Must match how the version itself was resolved. Without this a worktree
|
|
535
|
+
// inherits an installed version but never its `gitCommitSha`, so drift
|
|
536
|
+
// detection silently switches off for exactly the plugins that just started
|
|
537
|
+
// reporting as installed.
|
|
538
|
+
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
539
|
+
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
540
|
+
|
|
541
|
+
await Promise.all(
|
|
542
|
+
plugins.map(async (plugin) => {
|
|
543
|
+
if (!contentDriftCandidate(plugin)) return;
|
|
544
|
+
|
|
545
|
+
const entry = pickRegistryEntry(
|
|
546
|
+
registry.plugins[plugin.id],
|
|
547
|
+
scope,
|
|
548
|
+
target,
|
|
549
|
+
fallbackPaths,
|
|
550
|
+
);
|
|
551
|
+
if (!entry?.gitCommitSha) return;
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
plugin.contentStale = await hasContentDrift({
|
|
555
|
+
marketplace: plugin.marketplace,
|
|
556
|
+
pluginName: plugin.name,
|
|
557
|
+
installedSha: entry.gitCommitSha,
|
|
558
|
+
installedVersion: plugin.installedVersion,
|
|
559
|
+
});
|
|
560
|
+
} catch {
|
|
561
|
+
/* leave unflagged */
|
|
562
|
+
}
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** A dotted run of digits, optionally `v`-prefixed. Anything else is opaque. */
|
|
568
|
+
const SEMVER_ISH = /^\d+(\.\d+)*$/;
|
|
569
|
+
|
|
570
|
+
const normalize = (v: string) => v.replace(/^v/, "");
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Version comparison: 1 if a > b, -1 if a < b, 0 if equal, **null if the pair
|
|
574
|
+
* carries no ordering at all**.
|
|
575
|
+
*
|
|
576
|
+
* `null` is the whole point. This used to return 0 for every unorderable pair,
|
|
577
|
+
* and 0 means "equal" to every caller — so "I cannot tell" was indistinguishable
|
|
578
|
+
* from "they match". Measured against real registry values before the fix:
|
|
579
|
+
*
|
|
580
|
+
* compareVersions("517b2fcd1b60", "b819188d2eea") = 0 ← two DIFFERENT commits
|
|
581
|
+
* compareVersions("7.2.0", "517b2fcd1b60") = 1 ← a fabricated ordering
|
|
582
|
+
*
|
|
583
|
+
* Anthropic's official plugins record a commit sha as their version, so the
|
|
584
|
+
* first line made every one of them permanently un-updatable, and the second
|
|
585
|
+
* invented an update whenever a sha met a semver.
|
|
586
|
+
*
|
|
587
|
+
* Three cases, and only the first has an answer:
|
|
588
|
+
* - both dotted-numeric → compare numerically, shorter side zero-padded
|
|
589
|
+
* - both opaque → equal strings are 0; different strings are `null`,
|
|
590
|
+
* because two commit ids carry no order between them
|
|
591
|
+
* - one of each → `null`; there is no shared scale
|
|
592
|
+
*
|
|
593
|
+
* A missing side is `null` too. It used to be 0, which claimed an absent
|
|
594
|
+
* version matched a present one.
|
|
595
|
+
*
|
|
596
|
+
* Callers asking "should this be updated?" want {@link isBehind}, not this.
|
|
597
|
+
*/
|
|
598
|
+
export function compareVersions(
|
|
599
|
+
a: string | null | undefined,
|
|
600
|
+
b: string | null | undefined,
|
|
601
|
+
): number | null {
|
|
602
|
+
if (!a || !b) return null;
|
|
603
|
+
const A = normalize(a);
|
|
604
|
+
const B = normalize(b);
|
|
605
|
+
|
|
606
|
+
const aNumeric = SEMVER_ISH.test(A);
|
|
607
|
+
const bNumeric = SEMVER_ISH.test(B);
|
|
608
|
+
|
|
609
|
+
if (aNumeric !== bNumeric) return null; // no shared scale
|
|
610
|
+
if (!aNumeric) return A === B ? 0 : null; // two opaque ids: equal, or unordered
|
|
611
|
+
|
|
612
|
+
const partsA = A.split(".").map(Number);
|
|
613
|
+
const partsB = B.split(".").map(Number);
|
|
614
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
615
|
+
const numA = partsA[i] ?? 0;
|
|
616
|
+
const numB = partsB[i] ?? 0;
|
|
617
|
+
if (numA > numB) return 1;
|
|
618
|
+
if (numA < numB) return -1;
|
|
619
|
+
}
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Is `installed` out of date against what the catalog publishes as `latest`?
|
|
625
|
+
*
|
|
626
|
+
* `true` / `false` / `null`, where **null means unknown** — never a silent
|
|
627
|
+
* `false`. This is the question every update path actually asks, and keeping it
|
|
628
|
+
* separate from {@link compareVersions} is what lets the two opaque-id cases
|
|
629
|
+
* differ:
|
|
630
|
+
*
|
|
631
|
+
* - Ordering two commit shas is impossible, so `compareVersions` says `null`.
|
|
632
|
+
* - But the catalog is authoritative for what is published *now*, so an
|
|
633
|
+
* installed sha that differs from it is **not current** — `isBehind` says
|
|
634
|
+
* `true`. That is what makes a sha-versioned plugin updatable at all.
|
|
635
|
+
*
|
|
636
|
+
* Not installed is `null`, not `false`. The old inline check was
|
|
637
|
+
* `installedVersion && compare(...) > 0`, so a plugin with no install at all
|
|
638
|
+
* evaluated to `false` and was reported — and counted — as up to date.
|
|
639
|
+
*/
|
|
640
|
+
export function isBehind(
|
|
641
|
+
latest: string | null | undefined,
|
|
642
|
+
installed: string | null | undefined,
|
|
643
|
+
): boolean | null {
|
|
644
|
+
if (!latest || !installed) return null;
|
|
645
|
+
const cmp = compareVersions(latest, installed);
|
|
646
|
+
if (cmp !== null) return cmp > 0;
|
|
647
|
+
|
|
648
|
+
// Unorderable. Two opaque ids that differ still mean "not what is published".
|
|
649
|
+
const bothOpaque =
|
|
650
|
+
!SEMVER_ISH.test(normalize(latest)) &&
|
|
651
|
+
!SEMVER_ISH.test(normalize(installed));
|
|
652
|
+
return bothOpaque ? true : null;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Get installed plugin versions from settings.json (shared, not secret)
|
|
656
|
+
async function getInstalledPluginVersions(
|
|
657
|
+
projectPath?: string,
|
|
658
|
+
): Promise<Record<string, string>> {
|
|
659
|
+
const settings = await readSettings(projectPath);
|
|
660
|
+
// Settings alone under-report: Claude Code's own `plugin install` updates the
|
|
661
|
+
// registry without touching our settings key, so overlay the registry.
|
|
662
|
+
return getProjectInstalledPluginVersions(
|
|
663
|
+
settings.installedPluginVersions || {},
|
|
664
|
+
projectPath,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Save installed plugin version to settings.json
|
|
669
|
+
export async function saveInstalledPluginVersion(
|
|
670
|
+
pluginId: string,
|
|
671
|
+
version: string,
|
|
672
|
+
projectPath?: string,
|
|
673
|
+
): Promise<void> {
|
|
674
|
+
const settings = await readSettings(projectPath);
|
|
675
|
+
settings.installedPluginVersions = settings.installedPluginVersions || {};
|
|
676
|
+
settings.installedPluginVersions[pluginId] = version;
|
|
677
|
+
await writeSettings(settings, projectPath);
|
|
678
|
+
|
|
679
|
+
// Update installed_plugins.json registry
|
|
680
|
+
await updateInstalledPluginsRegistry(
|
|
681
|
+
pluginId,
|
|
682
|
+
version,
|
|
683
|
+
"project",
|
|
684
|
+
projectPath ? path.resolve(projectPath) : undefined,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The version Claude Code's own registry records for a plugin at a scope, read
|
|
690
|
+
* fresh from disk.
|
|
691
|
+
*
|
|
692
|
+
* Exists because `claude plugin install` takes NO version argument — it installs
|
|
693
|
+
* whatever the marketplace currently publishes. The version we asked for and the
|
|
694
|
+
* version we got are therefore different questions, and only this one can answer
|
|
695
|
+
* the second. Callers must record what this returns, never what they intended:
|
|
696
|
+
* writing the intent fabricates state that the drift gate then trusts.
|
|
697
|
+
*/
|
|
698
|
+
export async function readInstalledVersionForScope(
|
|
699
|
+
pluginId: string,
|
|
700
|
+
scope: PluginScope,
|
|
701
|
+
projectPath: string = process.cwd(),
|
|
702
|
+
): Promise<string | null> {
|
|
703
|
+
const registry = await readInstalledPluginsRegistry();
|
|
704
|
+
const entry = pickRegistryEntry(
|
|
705
|
+
registry.plugins?.[pluginId],
|
|
706
|
+
scope,
|
|
707
|
+
projectPath,
|
|
708
|
+
);
|
|
709
|
+
return entry?.version ?? null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Record an installed version in the settings file that owns the given scope.
|
|
714
|
+
*
|
|
715
|
+
* Claude Code's CLI does not maintain `installedPluginVersions`, so magus
|
|
716
|
+
* keeps its own copy. Writing it at the WRONG scope is worse than not writing
|
|
717
|
+
* it: it makes a scope claim a version it does not have installed. Writing the
|
|
718
|
+
* wrong VERSION is worse still — see {@link readInstalledVersionForScope}.
|
|
719
|
+
*/
|
|
720
|
+
export async function saveInstalledPluginVersionForScope(
|
|
721
|
+
pluginId: string,
|
|
722
|
+
version: string,
|
|
723
|
+
scope: PluginScope,
|
|
724
|
+
projectPath: string = process.cwd(),
|
|
725
|
+
): Promise<void> {
|
|
726
|
+
if (scope === "user") {
|
|
727
|
+
await saveGlobalInstalledPluginVersion(pluginId, version);
|
|
728
|
+
} else if (scope === "local") {
|
|
729
|
+
await saveLocalInstalledPluginVersion(pluginId, version, projectPath);
|
|
730
|
+
} else {
|
|
731
|
+
await saveInstalledPluginVersion(pluginId, version, projectPath);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Remove installed plugin version from settings.json
|
|
736
|
+
export async function removeInstalledPluginVersion(
|
|
737
|
+
pluginId: string,
|
|
738
|
+
projectPath?: string,
|
|
739
|
+
): Promise<void> {
|
|
740
|
+
const settings = await readSettings(projectPath);
|
|
741
|
+
if (settings.installedPluginVersions) {
|
|
742
|
+
delete settings.installedPluginVersions[pluginId];
|
|
743
|
+
}
|
|
744
|
+
if (settings.enabledPlugins) {
|
|
745
|
+
delete settings.enabledPlugins[pluginId];
|
|
746
|
+
}
|
|
747
|
+
await writeSettings(settings, projectPath);
|
|
748
|
+
|
|
749
|
+
// Remove from installed_plugins.json registry
|
|
750
|
+
await removeFromInstalledPluginsRegistry(
|
|
751
|
+
pluginId,
|
|
752
|
+
"project",
|
|
753
|
+
projectPath ? path.resolve(projectPath) : undefined,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Clear marketplace cache
|
|
758
|
+
export function clearMarketplaceCache(): void {
|
|
759
|
+
clearFetcherCache();
|
|
760
|
+
localMarketplacesPromise = null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Get local marketplaces (with Promise-based caching to prevent race conditions)
|
|
764
|
+
async function getLocalMarketplaces(): Promise<Map<string, LocalMarketplace>> {
|
|
765
|
+
if (localMarketplacesPromise === null) {
|
|
766
|
+
localMarketplacesPromise = scanLocalMarketplaces();
|
|
767
|
+
}
|
|
768
|
+
return localMarketplacesPromise;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Export local marketplaces for use in other modules
|
|
772
|
+
export async function getLocalMarketplacesInfo(): Promise<
|
|
773
|
+
Map<string, LocalMarketplace>
|
|
774
|
+
> {
|
|
775
|
+
return getLocalMarketplaces();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Track which marketplaces have already been auto-synced this session
|
|
779
|
+
// so we only attempt the update once per marketplace per magus run.
|
|
780
|
+
const autoSyncedMarketplaces = new Set<string>();
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* If the remote manifest lists plugins that aren't in the local cache,
|
|
784
|
+
* the local clone is stale. Log a warning but do NOT trigger a marketplace
|
|
785
|
+
* update from magus.
|
|
786
|
+
*
|
|
787
|
+
* Previously this called `updateMarketplace()` which invokes Claude Code's
|
|
788
|
+
* `cacheMarketplaceFromGit()` — a non-atomic delete-then-clone that can
|
|
789
|
+
* permanently delete the marketplace directory on clone failure.
|
|
790
|
+
* Claude Code's own background autoupdate handles marketplace refreshing
|
|
791
|
+
* after session start. See: ai-docs/plugin-marketplace-bug-investigation.md
|
|
792
|
+
*/
|
|
793
|
+
async function autoSyncIfStale(
|
|
794
|
+
mpName: string,
|
|
795
|
+
remotePluginNames: string[],
|
|
796
|
+
localMarketplaces: Map<string, LocalMarketplace>,
|
|
797
|
+
): Promise<Map<string, LocalMarketplace>> {
|
|
798
|
+
if (autoSyncedMarketplaces.has(mpName)) return localMarketplaces;
|
|
799
|
+
|
|
800
|
+
const localMp = localMarketplaces.get(mpName);
|
|
801
|
+
if (!localMp) return localMarketplaces;
|
|
802
|
+
|
|
803
|
+
const localNames = new Set(localMp.plugins.map((p) => p.name));
|
|
804
|
+
const missingPlugins = remotePluginNames.filter(
|
|
805
|
+
(name) => !localNames.has(name),
|
|
806
|
+
);
|
|
807
|
+
if (missingPlugins.length === 0) return localMarketplaces;
|
|
808
|
+
|
|
809
|
+
autoSyncedMarketplaces.add(mpName);
|
|
810
|
+
// Log stale state but don't trigger update — Claude Code handles refresh
|
|
811
|
+
console.log(
|
|
812
|
+
`ℹ Marketplace ${mpName} is stale (missing: ${missingPlugins.join(", ")}). Claude Code will auto-update on next session start.`,
|
|
813
|
+
);
|
|
814
|
+
return localMarketplaces;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export interface RefreshAndRepairResult {
|
|
818
|
+
refresh: never[];
|
|
819
|
+
repair: RepairMarketplaceResult[];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Refresh magus's internal cache
|
|
824
|
+
* Note: Marketplace updates should be done via Claude Code's /plugin marketplace update command
|
|
825
|
+
*/
|
|
826
|
+
export async function refreshAllMarketplaces(
|
|
827
|
+
onProgress?: ProgressCallback,
|
|
828
|
+
): Promise<RefreshAndRepairResult> {
|
|
829
|
+
onProgress?.({ current: 1, total: 1, name: "Clearing cache..." });
|
|
830
|
+
|
|
831
|
+
// Clear all caches to force fresh data. The on-disk catalog cache is awaited
|
|
832
|
+
// rather than fired and forgotten, so the refetch that follows cannot read the
|
|
833
|
+
// entries this is discarding.
|
|
834
|
+
clearMarketplaceCache();
|
|
835
|
+
await clearPersistedCatalogs();
|
|
836
|
+
|
|
837
|
+
// Auto-repair plugin.json files with missing agents/commands/skills
|
|
838
|
+
const repairResults = await repairAllMarketplaces();
|
|
839
|
+
|
|
840
|
+
return {
|
|
841
|
+
refresh: [],
|
|
842
|
+
repair: repairResults,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Re-export types for consumers
|
|
847
|
+
export type { ProgressCallback };
|