magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
defaultMarketplaces,
|
|
5
|
+
getAllMarketplaces,
|
|
6
|
+
} from "../../data/marketplaces.js";
|
|
7
|
+
import { loadActiveClosure } from "../../services/active-closure.js";
|
|
8
|
+
import {
|
|
9
|
+
type PluginScope,
|
|
10
|
+
addMarketplace as cliAddMarketplace,
|
|
11
|
+
repairPlugin as cliRepairPlugin,
|
|
12
|
+
uninstallPlugin as cliUninstallPlugin,
|
|
13
|
+
} from "../../services/claude-cli.js";
|
|
14
|
+
import {
|
|
15
|
+
getMcpEnvVars,
|
|
16
|
+
readSettings,
|
|
17
|
+
setMcpEnvVar,
|
|
18
|
+
} from "../../services/claude-settings.js";
|
|
19
|
+
import { clearContentDriftCache } from "../../services/content-drift.js";
|
|
20
|
+
import {
|
|
21
|
+
type MarketplaceFetchFailure,
|
|
22
|
+
type PluginInfo,
|
|
23
|
+
type ScopeStatus,
|
|
24
|
+
clearMarketplaceCache,
|
|
25
|
+
getAvailablePlugins,
|
|
26
|
+
getLocalMarketplacesInfo,
|
|
27
|
+
getMarketplaceFetchFailures,
|
|
28
|
+
isInstalledInScope,
|
|
29
|
+
refreshAllMarketplaces,
|
|
30
|
+
resolveScopeAction,
|
|
31
|
+
} from "../../services/plugin-manager.js";
|
|
32
|
+
import {
|
|
33
|
+
getPluginEnvRequirements,
|
|
34
|
+
getPluginSourcePath,
|
|
35
|
+
} from "../../services/plugin-mcp-config.js";
|
|
36
|
+
import {
|
|
37
|
+
checkMissingDeps,
|
|
38
|
+
getPluginSetupFromSource,
|
|
39
|
+
installPluginDeps,
|
|
40
|
+
} from "../../services/plugin-setup.js";
|
|
41
|
+
import { saveProfile } from "../../services/profiles.js";
|
|
42
|
+
import {
|
|
43
|
+
fetchPluginContents,
|
|
44
|
+
fetchPluginSkillDetail,
|
|
45
|
+
} from "../../services/skills-manager.js";
|
|
46
|
+
// The one update implementation, shared with `magus update`. See the header
|
|
47
|
+
// of update-engine.ts for what drifted while the TUI had its own loop.
|
|
48
|
+
import {
|
|
49
|
+
type ApplyReporter,
|
|
50
|
+
REAL_PLUGIN_DEPS,
|
|
51
|
+
applyPlugins,
|
|
52
|
+
installPluginInScope,
|
|
53
|
+
pluginNeedsWork,
|
|
54
|
+
updatePluginInScope,
|
|
55
|
+
} from "../../services/update-engine.js";
|
|
56
|
+
// The ONE plugin classifier, shared with `magus update`. Every state this
|
|
57
|
+
// screen shows or acts on — row text, detail panel, header counts, U / a, the
|
|
58
|
+
// scope picker — reads the same map it builds.
|
|
59
|
+
import {
|
|
60
|
+
type PluginUpdateItem,
|
|
61
|
+
classifyPluginRows,
|
|
62
|
+
countPluginStates,
|
|
63
|
+
scopeIsBehind,
|
|
64
|
+
} from "../../services/update-plan.js";
|
|
65
|
+
import {
|
|
66
|
+
diffVersions,
|
|
67
|
+
loadSeenVersions,
|
|
68
|
+
saveSeenVersions,
|
|
69
|
+
} from "../../services/version-snapshot.js";
|
|
70
|
+
import { fuzzyFilter } from "../../utils/fuzzy-search.js";
|
|
71
|
+
import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
|
|
72
|
+
import {
|
|
73
|
+
type PluginBrowserItem,
|
|
74
|
+
type PluginComponentItem,
|
|
75
|
+
type PluginPluginItem,
|
|
76
|
+
buildPluginBrowserItems,
|
|
77
|
+
noMoveReasons,
|
|
78
|
+
pluginContentsRepo,
|
|
79
|
+
} from "../adapters/pluginsAdapter.js";
|
|
80
|
+
import { EmptyFilterState } from "../components/EmptyFilterState.js";
|
|
81
|
+
import { ScrollableList } from "../components/ScrollableList.js";
|
|
82
|
+
import { ScreenLayout } from "../components/layout/index.js";
|
|
83
|
+
import { useKeyboard } from "../hooks/useKeyboard.js";
|
|
84
|
+
import {
|
|
85
|
+
renderPluginDetail,
|
|
86
|
+
renderPluginRow,
|
|
87
|
+
} from "../renderers/pluginRenderers.js";
|
|
88
|
+
import { useApp, useModal, useProgress } from "../state/AppContext.js";
|
|
89
|
+
import { useDimensions } from "../state/DimensionsContext.js";
|
|
90
|
+
import { asyncValue } from "../state/types.js";
|
|
91
|
+
import { theme } from "../theme.js";
|
|
92
|
+
|
|
93
|
+
/** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
|
|
94
|
+
function buildCatalogNotice(
|
|
95
|
+
input: Parameters<typeof buildCatalogNoticeText>[0],
|
|
96
|
+
): React.ReactNode {
|
|
97
|
+
const text = buildCatalogNoticeText(input);
|
|
98
|
+
if (!text) return null;
|
|
99
|
+
return <text fg={theme.colors.warning}>{text}</text>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function PluginsScreen() {
|
|
103
|
+
const { state, dispatch } = useApp();
|
|
104
|
+
const { plugins: pluginsState } = state;
|
|
105
|
+
const modal = useModal();
|
|
106
|
+
const progress = useProgress();
|
|
107
|
+
const dimensions = useDimensions();
|
|
108
|
+
|
|
109
|
+
const isSearchActive =
|
|
110
|
+
state.isSearching &&
|
|
111
|
+
state.currentRoute.screen === "plugins" &&
|
|
112
|
+
!state.modal;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Catalogs that could not be read from any authoritative source.
|
|
116
|
+
*
|
|
117
|
+
* Held in state rather than derived at render time because it is an answer
|
|
118
|
+
* from the last fetch and must survive until the next one — a failure the user
|
|
119
|
+
* cannot see is the bug this screen shipped with.
|
|
120
|
+
*/
|
|
121
|
+
const [catalogFailures, setCatalogFailures] = useState<
|
|
122
|
+
MarketplaceFetchFailure[]
|
|
123
|
+
>([]);
|
|
124
|
+
|
|
125
|
+
/** Re-render tick so the retry countdown in the notice actually counts down. */
|
|
126
|
+
const [nowTick, setNowTick] = useState(() => Date.now());
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The active profile's pins — the same values `magus update` plans with.
|
|
130
|
+
*
|
|
131
|
+
* `undefined` until the first fetch answers; `null` when no profile could be
|
|
132
|
+
* resolved (no manifest, an invalid one, or several with none active). In
|
|
133
|
+
* that case every row is judged against "latest" and the notice says so.
|
|
134
|
+
*/
|
|
135
|
+
const [pins, setPins] = useState<
|
|
136
|
+
Readonly<Record<string, string>> | null | undefined
|
|
137
|
+
>(undefined);
|
|
138
|
+
|
|
139
|
+
// Fetch data (always fetches all scopes)
|
|
140
|
+
const fetchData = useCallback(async () => {
|
|
141
|
+
dispatch({ type: "PLUGINS_DATA_LOADING" });
|
|
142
|
+
try {
|
|
143
|
+
const localMarketplaces = await getLocalMarketplacesInfo();
|
|
144
|
+
const allMarketplaces = getAllMarketplaces(localMarketplaces);
|
|
145
|
+
// Read-only, never throws. `resolveBins: false`: this screen needs only
|
|
146
|
+
// the plugin pins, not every declared plugin's binary requirements.
|
|
147
|
+
const [pluginData, active] = await Promise.all([
|
|
148
|
+
getAvailablePlugins(state.projectPath),
|
|
149
|
+
loadActiveClosure(state.projectPath || process.cwd(), {
|
|
150
|
+
resolveBins: false,
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
setPins(active?.closure.plugins ?? null);
|
|
154
|
+
|
|
155
|
+
// Which catalogs answered, and which only appeared to. Read straight
|
|
156
|
+
// after the fetch, while the record still describes this attempt.
|
|
157
|
+
//
|
|
158
|
+
// Only failures that survived every authoritative source are worth
|
|
159
|
+
// reporting: a rate-limited HTTP fetch that the git remote ref then
|
|
160
|
+
// answered is a non-event for the user, and reporting it would fire a
|
|
161
|
+
// warning about intended, working behaviour.
|
|
162
|
+
const unresolved = new Set(
|
|
163
|
+
pluginData.filter((p) => p.updateCheckFailed).map((p) => p.marketplace),
|
|
164
|
+
);
|
|
165
|
+
setCatalogFailures(
|
|
166
|
+
getMarketplaceFetchFailures().filter((f) =>
|
|
167
|
+
unresolved.has(f.marketplace),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Surface version changes made outside magus (Claude Code's own
|
|
172
|
+
// plugin update, a manual CLI call). Diffing against
|
|
173
|
+
// what we last rendered catches all of them; logging only what
|
|
174
|
+
// magus did would miss every one.
|
|
175
|
+
const current: Record<string, string> = {};
|
|
176
|
+
for (const p of pluginData) {
|
|
177
|
+
if (p.installedVersion) current[p.id] = p.installedVersion;
|
|
178
|
+
}
|
|
179
|
+
const projectPath = state.projectPath || process.cwd();
|
|
180
|
+
const previous = await loadSeenVersions(projectPath);
|
|
181
|
+
for (const change of diffVersions(previous, current)) {
|
|
182
|
+
const target = pluginData.find((p) => p.id === change.pluginId);
|
|
183
|
+
if (!target) continue;
|
|
184
|
+
if (change.kind === "updated") {
|
|
185
|
+
target.recentlyUpdatedFrom = change.from;
|
|
186
|
+
} else {
|
|
187
|
+
target.recentlyInstalled = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Await the write. Fire-and-forget raced the next fetchData() — a
|
|
191
|
+
// refresh or a post-action refetch could load the baseline before the
|
|
192
|
+
// previous save landed and re-report the same change.
|
|
193
|
+
await saveSeenVersions(projectPath, current);
|
|
194
|
+
|
|
195
|
+
dispatch({
|
|
196
|
+
type: "PLUGINS_DATA_SUCCESS",
|
|
197
|
+
marketplaces: allMarketplaces,
|
|
198
|
+
plugins: pluginData,
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
dispatch({
|
|
202
|
+
type: "PLUGINS_DATA_ERROR",
|
|
203
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}, [dispatch, state.projectPath]);
|
|
207
|
+
|
|
208
|
+
// Load data on mount or when dataRefreshVersion changes
|
|
209
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
fetchData();
|
|
212
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Tick the countdown, and refetch by itself once the rate-limit window reopens.
|
|
216
|
+
*
|
|
217
|
+
* The user asked "when can I try again" — so the screen answers it and then
|
|
218
|
+
* acts on it. Leaving them to press `r` and guess is what makes a rate limit
|
|
219
|
+
* feel like a dead end, and a mistimed guess spends another request against the
|
|
220
|
+
* limit for nothing.
|
|
221
|
+
*
|
|
222
|
+
* Only armed while a retry is actually pending, so an untroubled screen runs no
|
|
223
|
+
* timer at all.
|
|
224
|
+
*/
|
|
225
|
+
const nextRetryAt = useMemo(() => {
|
|
226
|
+
const times = catalogFailures
|
|
227
|
+
.map((f) => f.retryAt)
|
|
228
|
+
.filter((t): t is number => typeof t === "number");
|
|
229
|
+
return times.length > 0 ? Math.min(...times) : undefined;
|
|
230
|
+
}, [catalogFailures]);
|
|
231
|
+
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (nextRetryAt === undefined) return;
|
|
234
|
+
|
|
235
|
+
const tick = setInterval(() => {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
setNowTick(now);
|
|
238
|
+
if (now >= nextRetryAt) {
|
|
239
|
+
clearInterval(tick);
|
|
240
|
+
// The cooldown has expired, so the fetcher will really try again
|
|
241
|
+
// rather than short-circuit on its own negative cache.
|
|
242
|
+
clearMarketplaceCache();
|
|
243
|
+
fetchData();
|
|
244
|
+
}
|
|
245
|
+
}, 1000);
|
|
246
|
+
|
|
247
|
+
return () => clearInterval(tick);
|
|
248
|
+
}, [nextRetryAt, fetchData]);
|
|
249
|
+
|
|
250
|
+
// The data to render: fresh when loaded, else the previous list while a
|
|
251
|
+
// reload is in flight. Reading `status === "success"` alone is what made a
|
|
252
|
+
// tab switch blank the screen — the router remounts this component, which
|
|
253
|
+
// refetches, and every intermediate frame had nothing to show.
|
|
254
|
+
const shownMarketplaces = asyncValue(pluginsState.marketplaces);
|
|
255
|
+
const shownPlugins = asyncValue(pluginsState.plugins);
|
|
256
|
+
const isReloading =
|
|
257
|
+
pluginsState.plugins.status === "loading" && shownPlugins !== undefined;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One status per plugin that has a pin, computed ONCE per data/pins change.
|
|
261
|
+
* The rows, the detail panel, the header counts, U / a and the scope picker
|
|
262
|
+
* all read this map, so none of them can disagree with another — or with
|
|
263
|
+
* `magus update`, which runs the same classifier over the same pins.
|
|
264
|
+
*/
|
|
265
|
+
const statuses = useMemo(
|
|
266
|
+
(): Map<string, PluginUpdateItem> =>
|
|
267
|
+
shownPlugins ? classifyPluginRows(shownPlugins, pins ?? {}) : new Map(),
|
|
268
|
+
[shownPlugins, pins],
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
// Build list items (categories + plugins)
|
|
272
|
+
const allItems = useMemo((): PluginBrowserItem[] => {
|
|
273
|
+
if (!shownMarketplaces || !shownPlugins) return [];
|
|
274
|
+
return buildPluginBrowserItems({
|
|
275
|
+
marketplaces: shownMarketplaces,
|
|
276
|
+
plugins: shownPlugins,
|
|
277
|
+
collapsedMarketplaces: pluginsState.collapsedMarketplaces,
|
|
278
|
+
expandedPlugins: pluginsState.expandedPlugins,
|
|
279
|
+
pluginContents: pluginsState.pluginContents,
|
|
280
|
+
statuses,
|
|
281
|
+
});
|
|
282
|
+
}, [
|
|
283
|
+
shownMarketplaces,
|
|
284
|
+
shownPlugins,
|
|
285
|
+
pluginsState.collapsedMarketplaces,
|
|
286
|
+
pluginsState.expandedPlugins,
|
|
287
|
+
pluginsState.pluginContents,
|
|
288
|
+
statuses,
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
// Filter items by search query
|
|
292
|
+
const filteredItems = useMemo(() => {
|
|
293
|
+
const query = pluginsState.searchQuery.trim();
|
|
294
|
+
if (!query) return allItems;
|
|
295
|
+
|
|
296
|
+
// Only search plugins, not categories
|
|
297
|
+
const pluginItems = allItems.filter((item) => item.kind === "plugin");
|
|
298
|
+
const fuzzyResults = fuzzyFilter(pluginItems, query, (item) => item.label);
|
|
299
|
+
|
|
300
|
+
const matchedPluginIds = new Set<string>();
|
|
301
|
+
for (const result of fuzzyResults) {
|
|
302
|
+
matchedPluginIds.add(result.item.id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Walk allItems sequentially: include a category only if any plugin below matched
|
|
306
|
+
const categoryHasMatch = new Map<string, boolean>();
|
|
307
|
+
let currentCategoryId: string | null = null;
|
|
308
|
+
for (const item of allItems) {
|
|
309
|
+
if (item.kind === "category") {
|
|
310
|
+
currentCategoryId = item.id;
|
|
311
|
+
if (!categoryHasMatch.has(item.id)) {
|
|
312
|
+
categoryHasMatch.set(item.id, false);
|
|
313
|
+
}
|
|
314
|
+
} else if (item.kind === "plugin" && currentCategoryId) {
|
|
315
|
+
if (matchedPluginIds.has(item.id)) {
|
|
316
|
+
categoryHasMatch.set(currentCategoryId, true);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const result: PluginBrowserItem[] = [];
|
|
322
|
+
let currentCatIncluded = false;
|
|
323
|
+
currentCategoryId = null;
|
|
324
|
+
|
|
325
|
+
for (const item of allItems) {
|
|
326
|
+
if (item.kind === "category") {
|
|
327
|
+
currentCategoryId = item.id;
|
|
328
|
+
currentCatIncluded = categoryHasMatch.get(item.id) === true;
|
|
329
|
+
if (currentCatIncluded) result.push(item);
|
|
330
|
+
} else if (item.kind === "plugin" && currentCatIncluded) {
|
|
331
|
+
if (matchedPluginIds.has(item.id)) {
|
|
332
|
+
const matched = fuzzyResults.find((r) => r.item.id === item.id);
|
|
333
|
+
// Skill rows are not in the filtered result, so an expanded arrow
|
|
334
|
+
// here would point at nothing.
|
|
335
|
+
result.push({
|
|
336
|
+
...item,
|
|
337
|
+
matches: matched?.matches,
|
|
338
|
+
isExpanded: false,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return result;
|
|
345
|
+
}, [allItems, pluginsState.searchQuery]);
|
|
346
|
+
|
|
347
|
+
const selectableItems = useMemo(() => filteredItems, [filteredItems]);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read the file behind whichever component row the cursor has settled on.
|
|
351
|
+
*
|
|
352
|
+
* Debounced, and that is not cosmetic: each read is one GitHub API call, an
|
|
353
|
+
* unauthenticated client gets 60 an hour, and holding ↓ through the 286
|
|
354
|
+
* skills of a large plugin would otherwise fire a call per keypress and
|
|
355
|
+
* exhaust the budget for every other marketplace on screen. 200ms is below
|
|
356
|
+
* the point a deliberate move feels laggy and above key-repeat.
|
|
357
|
+
*
|
|
358
|
+
* MCP servers are skipped: their whole definition is the JSON already read
|
|
359
|
+
* during the contents listing, so there is no document to fetch.
|
|
360
|
+
*/
|
|
361
|
+
const selectedRow = selectableItems[pluginsState.selectedIndex];
|
|
362
|
+
const selectedComponent =
|
|
363
|
+
selectedRow?.kind === "component" && selectedRow.component.kind !== "mcp"
|
|
364
|
+
? (selectedRow as PluginComponentItem)
|
|
365
|
+
: undefined;
|
|
366
|
+
const detailKey = selectedComponent?.detailKey;
|
|
367
|
+
const detailRepo = selectedComponent?.repo;
|
|
368
|
+
const detailPath = selectedComponent?.component.repoPath;
|
|
369
|
+
const haveDetail = detailKey
|
|
370
|
+
? pluginsState.skillDetails.has(detailKey)
|
|
371
|
+
: true;
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!detailKey || !detailRepo || !detailPath) return;
|
|
375
|
+
if (haveDetail) return;
|
|
376
|
+
|
|
377
|
+
const timer = setTimeout(() => {
|
|
378
|
+
dispatch({ type: "PLUGINS_SKILL_DETAIL_LOADING", key: detailKey });
|
|
379
|
+
fetchPluginSkillDetail(detailRepo, detailPath)
|
|
380
|
+
.then((detail) =>
|
|
381
|
+
dispatch({
|
|
382
|
+
type: "PLUGINS_SKILL_DETAIL_SUCCESS",
|
|
383
|
+
key: detailKey,
|
|
384
|
+
detail,
|
|
385
|
+
}),
|
|
386
|
+
)
|
|
387
|
+
.catch((error: unknown) =>
|
|
388
|
+
dispatch({
|
|
389
|
+
type: "PLUGINS_SKILL_DETAIL_ERROR",
|
|
390
|
+
key: detailKey,
|
|
391
|
+
error: error instanceof Error ? error.message : String(error),
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
}, 200);
|
|
395
|
+
|
|
396
|
+
return () => clearTimeout(timer);
|
|
397
|
+
}, [dispatch, haveDetail, detailKey, detailPath, detailRepo]);
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Open or close a plugin's contents, fetching them the first time.
|
|
401
|
+
*
|
|
402
|
+
* The listing is kept per plugin id for the life of the session rather than
|
|
403
|
+
* refetched on each open: it costs a GitHub Tree API call, and an
|
|
404
|
+
* unauthenticated client gets 60 an hour across every marketplace on screen.
|
|
405
|
+
*/
|
|
406
|
+
const togglePluginContents = useCallback(
|
|
407
|
+
(item: PluginPluginItem) => {
|
|
408
|
+
const pluginId = item.plugin.id;
|
|
409
|
+
dispatch({ type: "PLUGINS_TOGGLE_PLUGIN_CONTENTS", pluginId });
|
|
410
|
+
|
|
411
|
+
if (item.isExpanded) return;
|
|
412
|
+
// A previous *success* is kept for the session. A previous failure is
|
|
413
|
+
// not: the usual cause is the GitHub hourly limit, which clears on its
|
|
414
|
+
// own, and caching the failure made a temporary condition look permanent
|
|
415
|
+
// with no way back except reloading the whole screen.
|
|
416
|
+
if (pluginsState.pluginContents.get(pluginId)?.status === "success") {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const marketplace = shownMarketplaces?.find(
|
|
421
|
+
(m) => m.name === item.plugin.marketplace,
|
|
422
|
+
);
|
|
423
|
+
const repo = marketplace
|
|
424
|
+
? pluginContentsRepo(item.plugin, marketplace)
|
|
425
|
+
: undefined;
|
|
426
|
+
if (!repo) return;
|
|
427
|
+
|
|
428
|
+
dispatch({ type: "PLUGINS_CONTENTS_LOADING", pluginId });
|
|
429
|
+
fetchPluginContents(repo, item.plugin.source, item.plugin.marketplace)
|
|
430
|
+
.then((components) =>
|
|
431
|
+
dispatch({ type: "PLUGINS_CONTENTS_SUCCESS", pluginId, components }),
|
|
432
|
+
)
|
|
433
|
+
.catch((error: unknown) =>
|
|
434
|
+
dispatch({
|
|
435
|
+
type: "PLUGINS_CONTENTS_ERROR",
|
|
436
|
+
pluginId,
|
|
437
|
+
error: error instanceof Error ? error.message : String(error),
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
},
|
|
441
|
+
[dispatch, pluginsState.pluginContents, shownMarketplaces],
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Keyboard handling
|
|
445
|
+
useKeyboard((event) => {
|
|
446
|
+
if (state.modal) return;
|
|
447
|
+
|
|
448
|
+
const hasQuery = pluginsState.searchQuery.length > 0;
|
|
449
|
+
|
|
450
|
+
if (event.name === "escape") {
|
|
451
|
+
if (hasQuery || isSearchActive) {
|
|
452
|
+
dispatch({ type: "PLUGINS_SET_SEARCH", query: "" });
|
|
453
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
454
|
+
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (event.name === "backspace" || event.name === "delete") {
|
|
460
|
+
if (hasQuery) {
|
|
461
|
+
const newQuery = pluginsState.searchQuery.slice(0, -1);
|
|
462
|
+
dispatch({ type: "PLUGINS_SET_SEARCH", query: newQuery });
|
|
463
|
+
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
464
|
+
if (newQuery.length === 0) {
|
|
465
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (event.name === "up" || event.name === "k") {
|
|
472
|
+
if (isSearchActive)
|
|
473
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
474
|
+
dispatch({
|
|
475
|
+
type: "PLUGINS_SELECT",
|
|
476
|
+
index: Math.max(0, pluginsState.selectedIndex - 1),
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (event.name === "down" || event.name === "j") {
|
|
481
|
+
if (isSearchActive)
|
|
482
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
483
|
+
dispatch({
|
|
484
|
+
type: "PLUGINS_SELECT",
|
|
485
|
+
index: Math.min(
|
|
486
|
+
selectableItems.length - 1,
|
|
487
|
+
pluginsState.selectedIndex + 1,
|
|
488
|
+
),
|
|
489
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (event.name === "enter" || event.name === "return") {
|
|
494
|
+
if (isSearchActive) {
|
|
495
|
+
dispatch({ type: "SET_SEARCHING", isSearching: false });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
handleSelect();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (
|
|
503
|
+
event.name === "left" ||
|
|
504
|
+
event.name === "right" ||
|
|
505
|
+
event.name === "<" ||
|
|
506
|
+
event.name === ">"
|
|
507
|
+
) {
|
|
508
|
+
const item = selectableItems[pluginsState.selectedIndex];
|
|
509
|
+
if (item?.kind === "category") {
|
|
510
|
+
dispatch({
|
|
511
|
+
type: "PLUGINS_TOGGLE_MARKETPLACE",
|
|
512
|
+
name: item.marketplace.name,
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (item?.kind === "plugin" && item.isExpandable) {
|
|
517
|
+
togglePluginContents(item);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (isSearchActive) {
|
|
523
|
+
if (
|
|
524
|
+
event.name.length === 1 &&
|
|
525
|
+
!event.ctrl &&
|
|
526
|
+
!event.meta &&
|
|
527
|
+
!/[0-9]/.test(event.name)
|
|
528
|
+
) {
|
|
529
|
+
dispatch({
|
|
530
|
+
type: "PLUGINS_SET_SEARCH",
|
|
531
|
+
query: pluginsState.searchQuery + event.name,
|
|
532
|
+
});
|
|
533
|
+
dispatch({ type: "PLUGINS_SELECT", index: 0 });
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (event.name === "/") {
|
|
539
|
+
dispatch({ type: "SET_SEARCHING", isSearching: true });
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (event.name === "r") handleRefresh();
|
|
544
|
+
else if (event.name === "n") handleShowAddMarketplaceInstructions();
|
|
545
|
+
else if (event.name === "t") handleShowTeamConfigHelp();
|
|
546
|
+
// `name` is ALWAYS the unshifted key — Shift+U parses as
|
|
547
|
+
// {name:"u", shift:true, sequence:"U"}, so `name === "U"` can never be
|
|
548
|
+
// true. It sat below the plain "u" branch, so pressing Shift+U silently
|
|
549
|
+
// installed at user scope instead of updating.
|
|
550
|
+
else if (event.name === "u" && event.shift) handleUpdate();
|
|
551
|
+
else if (event.name === "u") handleScopeToggle("user");
|
|
552
|
+
else if (event.name === "p") handleScopeToggle("project");
|
|
553
|
+
else if (event.name === "l") handleScopeToggle("local");
|
|
554
|
+
else if (event.name === "d") handleRemoveDeprecated();
|
|
555
|
+
else if (event.name === "a") handleUpdateAll();
|
|
556
|
+
else if (event.name === "s") handleSaveAsProfile();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
560
|
+
|
|
561
|
+
// ── Action handlers ────────────────────────────────────────────────────────
|
|
562
|
+
|
|
563
|
+
const handleRefresh = async () => {
|
|
564
|
+
progress.show("Refreshing cache...");
|
|
565
|
+
try {
|
|
566
|
+
const result = await refreshAllMarketplaces((p) => {
|
|
567
|
+
progress.show(`${p.name}`, p.current, p.total);
|
|
568
|
+
});
|
|
569
|
+
clearMarketplaceCache();
|
|
570
|
+
// Drift is answered by diffing against the clone's HEAD, so a refresh
|
|
571
|
+
// that moves HEAD invalidates every cached answer.
|
|
572
|
+
clearContentDriftCache();
|
|
573
|
+
progress.hide();
|
|
574
|
+
|
|
575
|
+
let message =
|
|
576
|
+
"Cache refreshed.\n\n" +
|
|
577
|
+
"To update marketplaces from GitHub, run in Claude Code:\n" +
|
|
578
|
+
" /plugin marketplace update\n\n" +
|
|
579
|
+
"Then refresh magus again with 'r'.";
|
|
580
|
+
|
|
581
|
+
if (result.repair.length > 0) {
|
|
582
|
+
const totalRepaired = result.repair.reduce(
|
|
583
|
+
(sum, r) => sum + r.repaired.length,
|
|
584
|
+
0,
|
|
585
|
+
);
|
|
586
|
+
message += `\n\nAuto-repaired ${totalRepaired} plugin(s) with missing agents/commands.`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
await modal.message("Refreshed", message, "success");
|
|
590
|
+
fetchData();
|
|
591
|
+
} catch (error) {
|
|
592
|
+
progress.hide();
|
|
593
|
+
await modal.message("Error", `Refresh failed: ${error}`, "error");
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Add a marketplace.
|
|
599
|
+
*
|
|
600
|
+
* The known ones are offered as a menu and added here — that covers every
|
|
601
|
+
* marketplace magus ships with, including magus, so a first run does not
|
|
602
|
+
* have to leave the tool. An arbitrary owner/repo still needs the terminal:
|
|
603
|
+
* there is no text-input modal, only confirm/message/select.
|
|
604
|
+
*/
|
|
605
|
+
const handleShowAddMarketplaceInstructions = async () => {
|
|
606
|
+
const installed = new Set(
|
|
607
|
+
pluginsState.marketplaces.status === "success"
|
|
608
|
+
? pluginsState.marketplaces.data.map((m) => m.name)
|
|
609
|
+
: [],
|
|
610
|
+
);
|
|
611
|
+
const addable = defaultMarketplaces.filter(
|
|
612
|
+
(m) => !installed.has(m.name) && !m.deprecated && m.source.repo,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
if (addable.length > 0) {
|
|
616
|
+
const choice = await modal.select(
|
|
617
|
+
"Add Marketplace",
|
|
618
|
+
"Pick one to add now, or choose Other for a repo not listed.",
|
|
619
|
+
[
|
|
620
|
+
...addable.map((m) => ({
|
|
621
|
+
label: m.displayName || m.name,
|
|
622
|
+
value: m.source.repo as string,
|
|
623
|
+
description: m.description,
|
|
624
|
+
})),
|
|
625
|
+
{ label: "Other…", value: "", description: "Any owner/repo" },
|
|
626
|
+
],
|
|
627
|
+
);
|
|
628
|
+
if (choice === null) return;
|
|
629
|
+
if (choice) {
|
|
630
|
+
try {
|
|
631
|
+
await cliAddMarketplace(choice);
|
|
632
|
+
await handleRefresh();
|
|
633
|
+
} catch (e) {
|
|
634
|
+
await modal.message(
|
|
635
|
+
"Could not add marketplace",
|
|
636
|
+
`${(e as Error).message}\n\nRun it yourself if this persists:\n\n` +
|
|
637
|
+
` claude plugin marketplace add ${choice}`,
|
|
638
|
+
"error",
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
await modal.message(
|
|
646
|
+
"Add Marketplace",
|
|
647
|
+
"For a marketplace that is not listed, run this in your terminal:\n\n" +
|
|
648
|
+
" claude plugin marketplace add owner/repo\n\n" +
|
|
649
|
+
"Auto-update is enabled by default for new marketplaces.\n\n" +
|
|
650
|
+
"After adding, refresh magus with 'r' to see it.",
|
|
651
|
+
"info",
|
|
652
|
+
);
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const handleShowTeamConfigHelp = async () => {
|
|
656
|
+
const helpText =
|
|
657
|
+
"TEAM CONFIGURATION FOR MARKETPLACES\n\n" +
|
|
658
|
+
"To configure marketplaces for your entire team:\n\n" +
|
|
659
|
+
"1. Add to .claude/settings.json (committed to git):\n\n" +
|
|
660
|
+
" {\n" +
|
|
661
|
+
' "extraKnownMarketplaces": {\n' +
|
|
662
|
+
' "company-tools": {\n' +
|
|
663
|
+
' "source": {\n' +
|
|
664
|
+
' "source": "github",\n' +
|
|
665
|
+
' "repo": "your-org/claude-plugins"\n' +
|
|
666
|
+
" }\n" +
|
|
667
|
+
" }\n" +
|
|
668
|
+
" },\n" +
|
|
669
|
+
' "enabledPlugins": {\n' +
|
|
670
|
+
' "code-formatter@company-tools": true\n' +
|
|
671
|
+
" }\n" +
|
|
672
|
+
" }\n\n" +
|
|
673
|
+
"2. Team members get prompted to install when they trust the folder\n\n" +
|
|
674
|
+
"3. Marketplaces are GLOBAL ONLY (managed by Claude Code)\n" +
|
|
675
|
+
" - Not project-specific\n" +
|
|
676
|
+
" - All team members share the same marketplace cache\n\n" +
|
|
677
|
+
"NOTE: Individual plugin enablement is still project-specific.\n" +
|
|
678
|
+
'Use "Project" scope in magus to enable for the team.';
|
|
679
|
+
|
|
680
|
+
await modal.message("Team Configuration", helpText, "info");
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Collect environment variables required by a plugin's MCP servers.
|
|
685
|
+
*/
|
|
686
|
+
const collectPluginEnvVars = async (
|
|
687
|
+
pluginName: string,
|
|
688
|
+
marketplace: string,
|
|
689
|
+
): Promise<boolean> => {
|
|
690
|
+
try {
|
|
691
|
+
const pluginSource = await getPluginSourcePath(marketplace, pluginName);
|
|
692
|
+
if (!pluginSource) return true;
|
|
693
|
+
|
|
694
|
+
const requirements = await getPluginEnvRequirements(
|
|
695
|
+
marketplace,
|
|
696
|
+
pluginSource,
|
|
697
|
+
);
|
|
698
|
+
if (requirements.length === 0) return true;
|
|
699
|
+
|
|
700
|
+
const existingEnvVars = await getMcpEnvVars(state.projectPath);
|
|
701
|
+
const missingVars = requirements.filter(
|
|
702
|
+
(req) => !existingEnvVars[req.name] && !process.env[req.name],
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
if (missingVars.length === 0) return true;
|
|
706
|
+
|
|
707
|
+
const serverNames = [...new Set(missingVars.map((v) => v.serverName))];
|
|
708
|
+
const wantToConfigure = await modal.confirm(
|
|
709
|
+
"Configure MCP Servers?",
|
|
710
|
+
`This plugin includes MCP servers (${serverNames.join(", ")}) that need ${missingVars.length} environment variable(s).\n\nConfigure now?`,
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
if (!wantToConfigure) {
|
|
714
|
+
await modal.message(
|
|
715
|
+
"Skipped Configuration",
|
|
716
|
+
"You can configure these variables later in the Environment Variables screen (press 4).",
|
|
717
|
+
"info",
|
|
718
|
+
);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
for (const req of missingVars) {
|
|
723
|
+
const existingProcessEnv = process.env[req.name];
|
|
724
|
+
if (existingProcessEnv) {
|
|
725
|
+
const useExisting = await modal.confirm(
|
|
726
|
+
`Use ${req.name}?`,
|
|
727
|
+
`${req.name} is already set in your environment.\n\nUse the existing value?`,
|
|
728
|
+
);
|
|
729
|
+
if (useExisting) {
|
|
730
|
+
await setMcpEnvVar(req.name, `\${${req.name}}`, state.projectPath);
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const value = await modal.input(
|
|
736
|
+
`Configure ${req.serverName}`,
|
|
737
|
+
`${req.label} (required):`,
|
|
738
|
+
"",
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
if (value === null) {
|
|
742
|
+
await modal.message(
|
|
743
|
+
"Configuration Incomplete",
|
|
744
|
+
"Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).",
|
|
745
|
+
"info",
|
|
746
|
+
);
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (value) {
|
|
751
|
+
await setMcpEnvVar(req.name, value, state.projectPath);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return true;
|
|
756
|
+
} catch (error) {
|
|
757
|
+
console.error("Error collecting plugin env vars:", error);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Install system dependencies required by a plugin's MCP servers.
|
|
764
|
+
*/
|
|
765
|
+
const installPluginSystemDeps = async (
|
|
766
|
+
pluginName: string,
|
|
767
|
+
marketplace: string,
|
|
768
|
+
): Promise<void> => {
|
|
769
|
+
try {
|
|
770
|
+
const setup = await getPluginSetupFromSource(marketplace, pluginName);
|
|
771
|
+
if (!setup) return;
|
|
772
|
+
|
|
773
|
+
const missing = await checkMissingDeps(setup);
|
|
774
|
+
const hasMissing =
|
|
775
|
+
(missing.pip?.length || 0) +
|
|
776
|
+
(missing.brew?.length || 0) +
|
|
777
|
+
(missing.npm?.length || 0) +
|
|
778
|
+
(missing.cargo?.length || 0) +
|
|
779
|
+
(missing.go?.length || 0) >
|
|
780
|
+
0;
|
|
781
|
+
|
|
782
|
+
if (!hasMissing) return;
|
|
783
|
+
|
|
784
|
+
const parts: string[] = [];
|
|
785
|
+
if (missing.pip?.length) parts.push(`pip: ${missing.pip.join(", ")}`);
|
|
786
|
+
if (missing.brew?.length) parts.push(`brew: ${missing.brew.join(", ")}`);
|
|
787
|
+
if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
|
|
788
|
+
if (missing.cargo?.length)
|
|
789
|
+
parts.push(`cargo: ${missing.cargo.join(", ")}`);
|
|
790
|
+
if (missing.go?.length) parts.push(`go: ${missing.go.join(", ")}`);
|
|
791
|
+
|
|
792
|
+
const wantInstall = await modal.confirm(
|
|
793
|
+
"Install Dependencies?",
|
|
794
|
+
`This plugin needs system dependencies:\n\n${parts.join("\n")}\n\nInstall now?`,
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
if (!wantInstall) return;
|
|
798
|
+
|
|
799
|
+
modal.loading("Installing dependencies...");
|
|
800
|
+
const result = await installPluginDeps(missing);
|
|
801
|
+
modal.hideModal();
|
|
802
|
+
|
|
803
|
+
if (result.failed.length > 0) {
|
|
804
|
+
const failMsg = result.failed
|
|
805
|
+
.map((f) => `${f.pkg}: ${f.error}`)
|
|
806
|
+
.join("\n");
|
|
807
|
+
await modal.message(
|
|
808
|
+
"Partial Install",
|
|
809
|
+
`Installed: ${result.installed.length}\nFailed:\n${failMsg}`,
|
|
810
|
+
"error",
|
|
811
|
+
);
|
|
812
|
+
} else if (result.installed.length > 0) {
|
|
813
|
+
await modal.message(
|
|
814
|
+
"Dependencies Installed",
|
|
815
|
+
`Installed ${result.installed.length} package(s):\n${result.installed.join(", ")}`,
|
|
816
|
+
"success",
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
} catch (error) {
|
|
820
|
+
console.error("Error installing plugin deps:", error);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const handleSelect = async () => {
|
|
825
|
+
const item = selectableItems[pluginsState.selectedIndex];
|
|
826
|
+
if (!item) return;
|
|
827
|
+
|
|
828
|
+
if (item.kind === "category") {
|
|
829
|
+
const mp = item.marketplace;
|
|
830
|
+
|
|
831
|
+
if (item.marketplaceEnabled) {
|
|
832
|
+
const isCollapsed = pluginsState.collapsedMarketplaces.has(mp.name);
|
|
833
|
+
if (isCollapsed) {
|
|
834
|
+
dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
|
|
835
|
+
} else if (item.pluginCount > 0) {
|
|
836
|
+
dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
|
|
837
|
+
} else {
|
|
838
|
+
await modal.message(
|
|
839
|
+
`Remove ${mp.displayName}?`,
|
|
840
|
+
`To remove this marketplace, run in Claude Code:\n\n /plugin marketplace remove ${mp.name}\n\nAfter removing, refresh magus with 'r' to update the display.`,
|
|
841
|
+
"info",
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
} else {
|
|
845
|
+
// Add it here rather than printing the command for the user to run.
|
|
846
|
+
// Telling someone to leave the tool, paste a command, come back and
|
|
847
|
+
// press 'r' is a step magus can just take: it already knows the
|
|
848
|
+
// repo, and addMarketplace() is the same call they would make.
|
|
849
|
+
const repo = mp.source.repo || mp.name;
|
|
850
|
+
const wantAdd = await modal.confirm(
|
|
851
|
+
`Add ${mp.displayName}?`,
|
|
852
|
+
`Clone ${repo} and register it as a marketplace?\n\nAuto-update is enabled by default.`,
|
|
853
|
+
);
|
|
854
|
+
if (wantAdd) {
|
|
855
|
+
try {
|
|
856
|
+
await cliAddMarketplace(repo);
|
|
857
|
+
await handleRefresh();
|
|
858
|
+
} catch (e) {
|
|
859
|
+
await modal.message(
|
|
860
|
+
"Could not add marketplace",
|
|
861
|
+
`${(e as Error).message}\n\nRun it yourself if this persists:\n\n claude plugin marketplace add ${repo}`,
|
|
862
|
+
"error",
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
} else if (item.kind === "plugin") {
|
|
868
|
+
const plugin = item.plugin;
|
|
869
|
+
const status = statuses.get(plugin.id);
|
|
870
|
+
|
|
871
|
+
// The label's arrow and what the key then does are ONE rule —
|
|
872
|
+
// `scopeIsBehind` — so the picker can never show no arrow and then
|
|
873
|
+
// update, or show an arrow and then uninstall.
|
|
874
|
+
const buildScopeLabel = (
|
|
875
|
+
name: string,
|
|
876
|
+
scopeKey: PluginScope,
|
|
877
|
+
scope: ScopeStatus | undefined,
|
|
878
|
+
desc: string,
|
|
879
|
+
) => {
|
|
880
|
+
const installed = isInstalledInScope(scope);
|
|
881
|
+
const ver = scope?.version;
|
|
882
|
+
let label = installed ? `● ${name}` : `○ ${name}`;
|
|
883
|
+
label += ` (${desc})`;
|
|
884
|
+
if (ver) label += ` v${ver}`;
|
|
885
|
+
if (scopeIsBehind(status, scopeKey) && status?.target)
|
|
886
|
+
label += ` → v${status.target}`;
|
|
887
|
+
// Enabled in settings with nothing installed. Without saying so the
|
|
888
|
+
// row reads exactly like a scope that was never enabled, and the
|
|
889
|
+
// user cannot tell that picking it repairs rather than adds.
|
|
890
|
+
if (!installed && scope?.enabled) label += " — not installed, repairs";
|
|
891
|
+
return label;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const scopeOptions = [
|
|
895
|
+
{
|
|
896
|
+
label: buildScopeLabel("User", "user", plugin.userScope, "global"),
|
|
897
|
+
value: "user",
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
label: buildScopeLabel(
|
|
901
|
+
"Project",
|
|
902
|
+
"project",
|
|
903
|
+
plugin.projectScope,
|
|
904
|
+
"team",
|
|
905
|
+
),
|
|
906
|
+
value: "project",
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
label: buildScopeLabel(
|
|
910
|
+
"Local",
|
|
911
|
+
"local",
|
|
912
|
+
plugin.localScope,
|
|
913
|
+
"private",
|
|
914
|
+
),
|
|
915
|
+
value: "local",
|
|
916
|
+
},
|
|
917
|
+
];
|
|
918
|
+
|
|
919
|
+
const scopeValue = await modal.select(
|
|
920
|
+
plugin.name,
|
|
921
|
+
"Select scope to toggle:",
|
|
922
|
+
scopeOptions,
|
|
923
|
+
);
|
|
924
|
+
if (scopeValue === null) return;
|
|
925
|
+
|
|
926
|
+
const selectedScope =
|
|
927
|
+
scopeValue === "user"
|
|
928
|
+
? plugin.userScope
|
|
929
|
+
: scopeValue === "project"
|
|
930
|
+
? plugin.projectScope
|
|
931
|
+
: plugin.localScope;
|
|
932
|
+
const scopeLabel =
|
|
933
|
+
scopeValue === "user"
|
|
934
|
+
? "User"
|
|
935
|
+
: scopeValue === "project"
|
|
936
|
+
? "Project"
|
|
937
|
+
: "Local";
|
|
938
|
+
|
|
939
|
+
// Decided from the registry-backed version, not the `enabledPlugins`
|
|
940
|
+
// flag alone — see resolveScopeAction. Update-versus-uninstall is the
|
|
941
|
+
// same `scopeIsBehind` the label above used.
|
|
942
|
+
const action = resolveScopeAction(
|
|
943
|
+
selectedScope,
|
|
944
|
+
scopeIsBehind(status, scopeValue as PluginScope),
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
try {
|
|
948
|
+
const scope = scopeValue as PluginScope;
|
|
949
|
+
if (action === "uninstall") {
|
|
950
|
+
modal.loading(
|
|
951
|
+
`Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
|
|
952
|
+
);
|
|
953
|
+
await cliUninstallPlugin(plugin.id, scope, state.projectPath);
|
|
954
|
+
} else if (action === "update") {
|
|
955
|
+
modal.loading(
|
|
956
|
+
`Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
957
|
+
);
|
|
958
|
+
// Scope is the user's explicit choice here, so this does NOT go
|
|
959
|
+
// through applyPlugins (which advances every scope behind). It
|
|
960
|
+
// still records the version READ BACK, not the catalog version.
|
|
961
|
+
await updatePluginInScope(
|
|
962
|
+
plugin.id,
|
|
963
|
+
scope,
|
|
964
|
+
state.projectPath || process.cwd(),
|
|
965
|
+
);
|
|
966
|
+
} else {
|
|
967
|
+
modal.loading(
|
|
968
|
+
`Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
969
|
+
);
|
|
970
|
+
// Same read-back as the update branch above. A fresh install is
|
|
971
|
+
// no more able to guarantee it got the catalog version than an
|
|
972
|
+
// update is — the marketplace serves whatever it publishes at
|
|
973
|
+
// the moment of the call — so recording the version we asked
|
|
974
|
+
// for made `install --check` compare that number against a copy
|
|
975
|
+
// of itself and report clean forever.
|
|
976
|
+
await installPluginInScope(
|
|
977
|
+
plugin.id,
|
|
978
|
+
scope,
|
|
979
|
+
state.projectPath || process.cwd(),
|
|
980
|
+
);
|
|
981
|
+
modal.hideModal();
|
|
982
|
+
await collectPluginEnvVars(plugin.name, plugin.marketplace);
|
|
983
|
+
await installPluginSystemDeps(plugin.name, plugin.marketplace);
|
|
984
|
+
}
|
|
985
|
+
if (action !== "install") {
|
|
986
|
+
modal.hideModal();
|
|
987
|
+
}
|
|
988
|
+
fetchData();
|
|
989
|
+
} catch (error) {
|
|
990
|
+
modal.hideModal();
|
|
991
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const handleUpdate = async () => {
|
|
997
|
+
const item = selectableItems[pluginsState.selectedIndex];
|
|
998
|
+
if (!item || item.kind !== "plugin") return;
|
|
999
|
+
// The row said "update" or "stale — reinstall"; nothing else is work.
|
|
1000
|
+
const status = statuses.get(item.plugin.id);
|
|
1001
|
+
if (status?.action !== "update" && status?.action !== "repair") return;
|
|
1002
|
+
|
|
1003
|
+
const plugin = item.plugin;
|
|
1004
|
+
const scope: PluginScope =
|
|
1005
|
+
pluginsState.scope === "global" ? "user" : "project";
|
|
1006
|
+
|
|
1007
|
+
// No version bump to install over — the files changed underneath an
|
|
1008
|
+
// unchanged version, and `plugin install` no-ops on "already installed".
|
|
1009
|
+
// Only uninstall+install re-copies. See claude-cli.ts repairPlugin.
|
|
1010
|
+
const repairing = status.action === "repair";
|
|
1011
|
+
|
|
1012
|
+
modal.loading(
|
|
1013
|
+
repairing
|
|
1014
|
+
? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
|
|
1015
|
+
: `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1016
|
+
);
|
|
1017
|
+
const reasons = noMoveReasons();
|
|
1018
|
+
try {
|
|
1019
|
+
// One plugin, but the SAME engine — so a single update records the
|
|
1020
|
+
// version that landed and touches every scope that is behind, exactly
|
|
1021
|
+
// as `magus update` does. The plan item IS the row's status: the
|
|
1022
|
+
// classifier's answer for this plugin under its real pin (the
|
|
1023
|
+
// profile's, or "latest" for an undeclared install — see pinForRow),
|
|
1024
|
+
// so what runs is exactly what the row claimed.
|
|
1025
|
+
const result = await applyPlugins(
|
|
1026
|
+
[status],
|
|
1027
|
+
state.projectPath || process.cwd(),
|
|
1028
|
+
REAL_PLUGIN_DEPS,
|
|
1029
|
+
reasons.reporter,
|
|
1030
|
+
);
|
|
1031
|
+
modal.hideModal();
|
|
1032
|
+
if (result.failed.length > 0) {
|
|
1033
|
+
await modal.message(
|
|
1034
|
+
"Error",
|
|
1035
|
+
`Failed to update ${plugin.name}`,
|
|
1036
|
+
"error",
|
|
1037
|
+
);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
// Ran, exited 0, moved nothing — and the row will not have moved
|
|
1041
|
+
// either. Saying nothing here is what made "press U, press U,
|
|
1042
|
+
// forever": the CLI names the cause, so the screen does too.
|
|
1043
|
+
if (result.unchanged.length > 0) {
|
|
1044
|
+
await modal.message(
|
|
1045
|
+
"No change",
|
|
1046
|
+
reasons.lines(result.unchanged),
|
|
1047
|
+
"info",
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
fetchData();
|
|
1051
|
+
} catch (error) {
|
|
1052
|
+
modal.hideModal();
|
|
1053
|
+
await modal.message("Error", `Failed to update: ${error}`, "error");
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* "Update all" — the SAME planner and the SAME apply engine `magus
|
|
1059
|
+
* update` runs. Only the selection differs, and only because a screen and a
|
|
1060
|
+
* profile genuinely mean different sets: here it is "everything listed with
|
|
1061
|
+
* an update", there it is "what the profile declares".
|
|
1062
|
+
*
|
|
1063
|
+
* This used to be a hand-rolled loop, and it had drifted from the CLI in
|
|
1064
|
+
* three ways that were bugs, not choices — it touched one scope derived from
|
|
1065
|
+
* the open tab, it recorded the version it EXPECTED rather than the one that
|
|
1066
|
+
* landed, and it never reported a pin the installer could not honour. All
|
|
1067
|
+
* three are fixed by not having a second implementation.
|
|
1068
|
+
*/
|
|
1069
|
+
const handleUpdateAll = async () => {
|
|
1070
|
+
if (pluginsState.plugins.status !== "success") return;
|
|
1071
|
+
|
|
1072
|
+
// Exactly the rows that say "update" or "stale — reinstall", each planned
|
|
1073
|
+
// under its real pin. Content-stale plugins are included: they need
|
|
1074
|
+
// repairing, and excluding them meant "update all" left the machine with
|
|
1075
|
+
// silently outdated files.
|
|
1076
|
+
const items = [...statuses.values()]
|
|
1077
|
+
.filter((s) => s.action === "update" || s.action === "repair")
|
|
1078
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1079
|
+
if (items.length === 0) return;
|
|
1080
|
+
|
|
1081
|
+
const projectPath = state.projectPath || process.cwd();
|
|
1082
|
+
const total = items.filter(pluginNeedsWork).length;
|
|
1083
|
+
|
|
1084
|
+
let done = 0;
|
|
1085
|
+
const reasons = noMoveReasons();
|
|
1086
|
+
const reporter: ApplyReporter = {
|
|
1087
|
+
begin: (name) => {
|
|
1088
|
+
done++;
|
|
1089
|
+
modal.loading(`Updating ${name} (${done}/${total})…`);
|
|
1090
|
+
},
|
|
1091
|
+
finish: reasons.reporter.finish,
|
|
1092
|
+
detach: (work) => work(),
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
const result = await applyPlugins(
|
|
1097
|
+
items,
|
|
1098
|
+
projectPath,
|
|
1099
|
+
REAL_PLUGIN_DEPS,
|
|
1100
|
+
reporter,
|
|
1101
|
+
);
|
|
1102
|
+
modal.hideModal();
|
|
1103
|
+
if (result.failed.length > 0) {
|
|
1104
|
+
await modal.message(
|
|
1105
|
+
"Some updates failed",
|
|
1106
|
+
result.failed.join(", "),
|
|
1107
|
+
"error",
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
if (result.unchanged.length > 0) {
|
|
1111
|
+
await modal.message(
|
|
1112
|
+
"No change",
|
|
1113
|
+
reasons.lines(result.unchanged),
|
|
1114
|
+
"info",
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
fetchData();
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
modal.hideModal();
|
|
1120
|
+
await modal.message("Error", `Failed to update: ${error}`, "error");
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
const handleScopeToggle = async (scope: "user" | "project" | "local") => {
|
|
1125
|
+
const item = selectableItems[pluginsState.selectedIndex];
|
|
1126
|
+
if (!item || item.kind !== "plugin") return;
|
|
1127
|
+
|
|
1128
|
+
const plugin = item.plugin;
|
|
1129
|
+
const scopeLabel =
|
|
1130
|
+
scope === "user" ? "User" : scope === "project" ? "Project" : "Local";
|
|
1131
|
+
|
|
1132
|
+
const scopeData =
|
|
1133
|
+
scope === "user"
|
|
1134
|
+
? plugin.userScope
|
|
1135
|
+
: scope === "project"
|
|
1136
|
+
? plugin.projectScope
|
|
1137
|
+
: plugin.localScope;
|
|
1138
|
+
|
|
1139
|
+
// This used to key off `scopeData.enabled` alone, so a plugin the list
|
|
1140
|
+
// had just labelled "not installed" answered its own scope key by
|
|
1141
|
+
// uninstalling itself — the `enabledPlugins` flag was set, the registry
|
|
1142
|
+
// entry was missing, and only the flag was consulted. Update-versus-
|
|
1143
|
+
// uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
|
|
1144
|
+
// scope picker labels with.
|
|
1145
|
+
const action = resolveScopeAction(
|
|
1146
|
+
scopeData,
|
|
1147
|
+
scopeIsBehind(statuses.get(plugin.id), scope),
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
try {
|
|
1151
|
+
if (action === "uninstall") {
|
|
1152
|
+
modal.loading(
|
|
1153
|
+
`Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
|
|
1154
|
+
);
|
|
1155
|
+
await cliUninstallPlugin(plugin.id, scope, state.projectPath);
|
|
1156
|
+
} else if (action === "update") {
|
|
1157
|
+
modal.loading(
|
|
1158
|
+
`Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1159
|
+
);
|
|
1160
|
+
// Explicit scope, read-back version — see the note above.
|
|
1161
|
+
await updatePluginInScope(
|
|
1162
|
+
plugin.id,
|
|
1163
|
+
scope,
|
|
1164
|
+
state.projectPath || process.cwd(),
|
|
1165
|
+
);
|
|
1166
|
+
} else {
|
|
1167
|
+
modal.loading(
|
|
1168
|
+
`Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1169
|
+
);
|
|
1170
|
+
// Read-back on install too — see the note on the same branch above.
|
|
1171
|
+
await installPluginInScope(
|
|
1172
|
+
plugin.id,
|
|
1173
|
+
scope,
|
|
1174
|
+
state.projectPath || process.cwd(),
|
|
1175
|
+
);
|
|
1176
|
+
modal.hideModal();
|
|
1177
|
+
await collectPluginEnvVars(plugin.name, plugin.marketplace);
|
|
1178
|
+
await installPluginSystemDeps(plugin.name, plugin.marketplace);
|
|
1179
|
+
}
|
|
1180
|
+
if (action !== "install") {
|
|
1181
|
+
modal.hideModal();
|
|
1182
|
+
}
|
|
1183
|
+
fetchData();
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
modal.hideModal();
|
|
1186
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
1187
|
+
}
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const handleRemoveDeprecated = async () => {
|
|
1191
|
+
const item = selectableItems[pluginsState.selectedIndex];
|
|
1192
|
+
if (!item || item.kind !== "plugin" || !item.plugin.isOrphaned) return;
|
|
1193
|
+
|
|
1194
|
+
const plugin = item.plugin;
|
|
1195
|
+
|
|
1196
|
+
// A plugin that moved marketplace is not deprecated — it is still
|
|
1197
|
+
// published under a new namespace. Offering only "remove" silently drops
|
|
1198
|
+
// something the user still wants (the magus → magus-marketing split left
|
|
1199
|
+
// video-editing and image-generate in this state).
|
|
1200
|
+
let migrateTo: string | undefined;
|
|
1201
|
+
if (plugin.movedTo) {
|
|
1202
|
+
const choice = await modal.select(
|
|
1203
|
+
"Plugin moved",
|
|
1204
|
+
`${plugin.name} is no longer published by "${plugin.marketplace}", but "${plugin.movedTo}" still publishes it.`,
|
|
1205
|
+
[
|
|
1206
|
+
{
|
|
1207
|
+
label: `Migrate to ${plugin.name}@${plugin.movedTo}`,
|
|
1208
|
+
value: "migrate",
|
|
1209
|
+
},
|
|
1210
|
+
{ label: "Remove it entirely", value: "remove" },
|
|
1211
|
+
],
|
|
1212
|
+
);
|
|
1213
|
+
if (choice === null) return;
|
|
1214
|
+
if (choice === "migrate") migrateTo = plugin.movedTo;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
// Remove from all scopes — try all to clean up stale references
|
|
1219
|
+
const scopes: PluginScope[] = ["user", "project", "local"];
|
|
1220
|
+
const removedFrom: PluginScope[] = [];
|
|
1221
|
+
for (const scope of scopes) {
|
|
1222
|
+
try {
|
|
1223
|
+
modal.loading(
|
|
1224
|
+
`Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
|
|
1225
|
+
);
|
|
1226
|
+
const wasInstalled =
|
|
1227
|
+
scope === "user"
|
|
1228
|
+
? plugin.userScope?.enabled
|
|
1229
|
+
: scope === "project"
|
|
1230
|
+
? plugin.projectScope?.enabled
|
|
1231
|
+
: plugin.localScope?.enabled;
|
|
1232
|
+
await cliUninstallPlugin(plugin.id, scope, state.projectPath);
|
|
1233
|
+
if (wasInstalled) removedFrom.push(scope);
|
|
1234
|
+
} catch {
|
|
1235
|
+
// Ignore errors for scopes where it doesn't exist
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (migrateTo) {
|
|
1240
|
+
// Reinstall under the new namespace in every scope it occupied, so
|
|
1241
|
+
// the migration does not quietly change where the plugin is enabled.
|
|
1242
|
+
const newId = `${plugin.name}@${migrateTo}`;
|
|
1243
|
+
for (const scope of removedFrom.length > 0
|
|
1244
|
+
? removedFrom
|
|
1245
|
+
: ["project" as PluginScope]) {
|
|
1246
|
+
modal.loading(
|
|
1247
|
+
`Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
|
|
1248
|
+
);
|
|
1249
|
+
// The migrated id is a plugin magus has never recorded a
|
|
1250
|
+
// version for, so going through the read-back is what stops the
|
|
1251
|
+
// rename leaving it permanently version-less — a plugin with no
|
|
1252
|
+
// recorded version never reports an available update.
|
|
1253
|
+
await installPluginInScope(
|
|
1254
|
+
newId,
|
|
1255
|
+
scope,
|
|
1256
|
+
state.projectPath || process.cwd(),
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
modal.hideModal();
|
|
1262
|
+
fetchData();
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
modal.hideModal();
|
|
1265
|
+
await modal.message("Error", `Failed to remove: ${error}`, "error");
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
const handleSaveAsProfile = async () => {
|
|
1270
|
+
const settings = await readSettings(state.projectPath);
|
|
1271
|
+
const enabledPlugins = settings.enabledPlugins ?? {};
|
|
1272
|
+
|
|
1273
|
+
const name = await modal.input("Save Profile", "Profile name:");
|
|
1274
|
+
if (name === null || !name.trim()) return;
|
|
1275
|
+
|
|
1276
|
+
const scopeChoice = await modal.select(
|
|
1277
|
+
"Save to scope",
|
|
1278
|
+
"Where should this profile be saved?",
|
|
1279
|
+
[
|
|
1280
|
+
{
|
|
1281
|
+
label: "User — ~/.claude/profiles.json (available everywhere)",
|
|
1282
|
+
value: "user",
|
|
1283
|
+
},
|
|
1284
|
+
{
|
|
1285
|
+
label: "Project — .claude/profiles.json (shared with team via git)",
|
|
1286
|
+
value: "project",
|
|
1287
|
+
},
|
|
1288
|
+
],
|
|
1289
|
+
);
|
|
1290
|
+
if (scopeChoice === null) return;
|
|
1291
|
+
|
|
1292
|
+
const scope = scopeChoice as "user" | "project";
|
|
1293
|
+
|
|
1294
|
+
modal.loading("Saving profile...");
|
|
1295
|
+
try {
|
|
1296
|
+
await saveProfile(name.trim(), enabledPlugins, scope, state.projectPath);
|
|
1297
|
+
modal.hideModal();
|
|
1298
|
+
await modal.message(
|
|
1299
|
+
"Saved",
|
|
1300
|
+
`Profile "${name.trim()}" saved.\nPress 6 to manage profiles.`,
|
|
1301
|
+
"success",
|
|
1302
|
+
);
|
|
1303
|
+
} catch (error) {
|
|
1304
|
+
modal.hideModal();
|
|
1305
|
+
await modal.message("Error", `Failed to save profile: ${error}`, "error");
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
// ── Render ─────────────────────────────────────────────────────────────────
|
|
1310
|
+
|
|
1311
|
+
// Only a genuine first load takes over the screen. A reload keeps the list on
|
|
1312
|
+
// screen and says so in the notice line instead.
|
|
1313
|
+
if (!shownMarketplaces || !shownPlugins) {
|
|
1314
|
+
if (
|
|
1315
|
+
pluginsState.marketplaces.status === "loading" ||
|
|
1316
|
+
pluginsState.plugins.status === "loading"
|
|
1317
|
+
) {
|
|
1318
|
+
return (
|
|
1319
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
1320
|
+
<text fg={theme.colors.accent}>
|
|
1321
|
+
<strong>magus Plugins</strong>
|
|
1322
|
+
</text>
|
|
1323
|
+
<text fg={theme.colors.muted}>
|
|
1324
|
+
Loading plugin catalogs from GitHub…
|
|
1325
|
+
</text>
|
|
1326
|
+
</box>
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (
|
|
1332
|
+
pluginsState.marketplaces.status === "error" ||
|
|
1333
|
+
pluginsState.plugins.status === "error"
|
|
1334
|
+
) {
|
|
1335
|
+
// Name the error. "Error loading data" told the user nothing and sent them
|
|
1336
|
+
// to the debug pane to find out what magus already knew.
|
|
1337
|
+
const failure =
|
|
1338
|
+
pluginsState.plugins.status === "error"
|
|
1339
|
+
? pluginsState.plugins.error
|
|
1340
|
+
: pluginsState.marketplaces.status === "error"
|
|
1341
|
+
? pluginsState.marketplaces.error
|
|
1342
|
+
: undefined;
|
|
1343
|
+
return (
|
|
1344
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
1345
|
+
<text fg={theme.colors.accent}>
|
|
1346
|
+
<strong>magus Plugins</strong>
|
|
1347
|
+
</text>
|
|
1348
|
+
<text fg={theme.colors.danger}>
|
|
1349
|
+
Could not load plugins: {failure?.message ?? "unknown error"}
|
|
1350
|
+
</text>
|
|
1351
|
+
<text fg={theme.colors.muted}>Press r to retry.</text>
|
|
1352
|
+
</box>
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const selectedItem = selectableItems[pluginsState.selectedIndex];
|
|
1357
|
+
|
|
1358
|
+
const footerHints = isSearchActive
|
|
1359
|
+
? [
|
|
1360
|
+
{ keys: ["type"], label: "filter" },
|
|
1361
|
+
{ keys: ["Enter"], label: "done" },
|
|
1362
|
+
{ keys: ["Esc"], label: "clear" },
|
|
1363
|
+
]
|
|
1364
|
+
: [
|
|
1365
|
+
{ keys: ["u", "/", "p", "/", "l"], label: "toggle" },
|
|
1366
|
+
{ keys: ["U"], label: "update" },
|
|
1367
|
+
{ keys: ["a"], label: "all" },
|
|
1368
|
+
{ keys: ["s"], label: "profile" },
|
|
1369
|
+
{ keys: ["/"], label: "search" },
|
|
1370
|
+
];
|
|
1371
|
+
|
|
1372
|
+
const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
|
|
1373
|
+
const plugins: PluginInfo[] = shownPlugins ?? [];
|
|
1374
|
+
// "installed" must mean the same thing here as in the rows beside it. This
|
|
1375
|
+
// counted `p.enabled` — the settings flag — so the header read "11 installed"
|
|
1376
|
+
// on a screen whose every row said "not installed". Same predicate as the
|
|
1377
|
+
// row and the detail panel now.
|
|
1378
|
+
const isInstalledAnywhere = (p: PluginInfo) =>
|
|
1379
|
+
isInstalledInScope(p.userScope) ||
|
|
1380
|
+
isInstalledInScope(p.projectScope) ||
|
|
1381
|
+
isInstalledInScope(p.localScope);
|
|
1382
|
+
const installedCount = plugins.filter(isInstalledAnywhere).length;
|
|
1383
|
+
// Every state count is the classifier's, over the same map the rows read,
|
|
1384
|
+
// so a header can never count a row the list beside it does not show.
|
|
1385
|
+
const counts = countPluginStates(statuses.values());
|
|
1386
|
+
const updateCount = counts.update + counts.repair;
|
|
1387
|
+
// Cannot tell whether it is current. Counted and shown separately: folding
|
|
1388
|
+
// it into `updateCount` would overstate, and folding it into silence is the
|
|
1389
|
+
// "cannot tell rendered as fine" bug.
|
|
1390
|
+
const unknownCount = counts.unknown;
|
|
1391
|
+
const installCount = counts.install;
|
|
1392
|
+
const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
|
|
1393
|
+
const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
|
|
1394
|
+
|
|
1395
|
+
const catalogNotice = buildCatalogNotice({
|
|
1396
|
+
failures: catalogFailures,
|
|
1397
|
+
unverifiedPlugins: [...statuses.values()].filter(
|
|
1398
|
+
(s) => s.reason === "catalog-unverified",
|
|
1399
|
+
).length,
|
|
1400
|
+
width: dimensions.terminalWidth,
|
|
1401
|
+
now: nowTick,
|
|
1402
|
+
});
|
|
1403
|
+
// Without a resolved profile, exact pins are unknown here and every row is
|
|
1404
|
+
// judged against "latest". Saying so is what keeps a row that disagrees with
|
|
1405
|
+
// `magus update <profile>` from reading as a contradiction.
|
|
1406
|
+
const notice = isReloading ? (
|
|
1407
|
+
<text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
|
|
1408
|
+
) : (
|
|
1409
|
+
(catalogNotice ??
|
|
1410
|
+
(pins === null ? (
|
|
1411
|
+
<text fg={theme.colors.muted}>
|
|
1412
|
+
profile not resolved — states judged against latest
|
|
1413
|
+
</text>
|
|
1414
|
+
) : null))
|
|
1415
|
+
);
|
|
1416
|
+
|
|
1417
|
+
return (
|
|
1418
|
+
<ScreenLayout
|
|
1419
|
+
title="magus Plugins"
|
|
1420
|
+
subtitle={subtitle}
|
|
1421
|
+
notice={notice}
|
|
1422
|
+
currentScreen="plugins"
|
|
1423
|
+
search={{
|
|
1424
|
+
isActive: isSearchActive,
|
|
1425
|
+
query: pluginsState.searchQuery,
|
|
1426
|
+
placeholder: searchPlaceholder,
|
|
1427
|
+
}}
|
|
1428
|
+
footerHints={footerHints}
|
|
1429
|
+
listPanel={
|
|
1430
|
+
<box flexDirection="column">
|
|
1431
|
+
<ScrollableList
|
|
1432
|
+
items={selectableItems}
|
|
1433
|
+
selectedIndex={pluginsState.selectedIndex}
|
|
1434
|
+
renderItem={renderPluginRow}
|
|
1435
|
+
maxHeight={dimensions.listPanelHeight}
|
|
1436
|
+
getKey={(item) => item.id}
|
|
1437
|
+
/>
|
|
1438
|
+
{pluginsState.searchQuery && selectableItems.length === 0 && (
|
|
1439
|
+
<EmptyFilterState
|
|
1440
|
+
query={pluginsState.searchQuery}
|
|
1441
|
+
entityName="plugins"
|
|
1442
|
+
/>
|
|
1443
|
+
)}
|
|
1444
|
+
</box>
|
|
1445
|
+
}
|
|
1446
|
+
// A skill's panel now holds its whole SKILL.md, which is long enough to
|
|
1447
|
+
// scroll. Without this the pane kept the previous row's scroll offset,
|
|
1448
|
+
// so moving to the next skill landed you in the middle of a document
|
|
1449
|
+
// you had not started reading.
|
|
1450
|
+
detailKey={selectedItem?.id}
|
|
1451
|
+
detailPanel={renderPluginDetail(
|
|
1452
|
+
selectedItem,
|
|
1453
|
+
pluginsState.collapsedMarketplaces,
|
|
1454
|
+
pluginsState.pluginContents,
|
|
1455
|
+
pluginsState.skillDetails,
|
|
1456
|
+
// ScreenLayout gives the detail pane 50% of the terminal, then takes
|
|
1457
|
+
// a column for its left padding and one for the scrollbar.
|
|
1458
|
+
Math.max(24, Math.floor(dimensions.terminalWidth / 2) - 3),
|
|
1459
|
+
)}
|
|
1460
|
+
/>
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
export default PluginsScreen;
|