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,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE place plugin state is decided, and the planning for `magus update`.
|
|
3
|
+
*
|
|
4
|
+
* `classifyPlugin` answers "what state is this plugin in, and why" for a plugin
|
|
5
|
+
* id, the version the profile pins it to, and the catalog. Every consumer reads
|
|
6
|
+
* its answer and nothing else: `magus update`'s plan, and in the TUI the
|
|
7
|
+
* Plugins row text, the detail panel, the header counts, the `U`/`a` actions
|
|
8
|
+
* and the scope picker. The TUI used to derive the same state from raw
|
|
9
|
+
* `PluginInfo` flags with its own rules, and the two front ends drifted — the
|
|
10
|
+
* same plugin, in the same project, could read green in one and red in the
|
|
11
|
+
* other, or carry a different reason in each.
|
|
12
|
+
*
|
|
13
|
+
* Tools follow the same rule: `planBinUpdates` plans from `classifyTool`
|
|
14
|
+
* (services/tool-probe.ts), the same classifier the TUI's CLI Tools screen
|
|
15
|
+
* renders, so a tool has one state in both front ends.
|
|
16
|
+
*
|
|
17
|
+
* `install` makes the machine match the manifest's pins. `update` advances it:
|
|
18
|
+
* anything the active profile declares but the machine lacks gets installed,
|
|
19
|
+
* and anything pinned "latest" moves to the newest version the marketplace
|
|
20
|
+
* catalog offers. Everything here is a pure function of a closure plus captured
|
|
21
|
+
* machine state, so the decisions are unit-testable and the callers only do IO.
|
|
22
|
+
*
|
|
23
|
+
* The one rule worth stating out loud: "cannot tell" must never be reported as
|
|
24
|
+
* "up to date". `PluginInfo.updateCheckFailed` exists because a rate-limited
|
|
25
|
+
* fetch once compared the installed version against a stale local clone, found
|
|
26
|
+
* them equal, and rendered the failure as a clean bill of health. That case, and
|
|
27
|
+
* every other one where the answer is not provable, gets its own action
|
|
28
|
+
* (`unknown`) rather than folding into `current`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
|
|
32
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
33
|
+
import {
|
|
34
|
+
type PluginInfo,
|
|
35
|
+
compareVersions,
|
|
36
|
+
isBehind,
|
|
37
|
+
} from "./plugin-manager.js";
|
|
38
|
+
import {
|
|
39
|
+
type ToolFacts,
|
|
40
|
+
type ToolReason,
|
|
41
|
+
type ToolState,
|
|
42
|
+
type ToolStatus,
|
|
43
|
+
classifyTool,
|
|
44
|
+
} from "./tool-probe.js";
|
|
45
|
+
|
|
46
|
+
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export type PluginUpdateAction =
|
|
49
|
+
/** Declared by the profile, installed nowhere. */
|
|
50
|
+
| "install"
|
|
51
|
+
/** Installed at a version other than the one we should be on. */
|
|
52
|
+
| "update"
|
|
53
|
+
/** Same version, different files — only a reinstall delivers the content. */
|
|
54
|
+
| "repair"
|
|
55
|
+
/** Already on the right version. */
|
|
56
|
+
| "current"
|
|
57
|
+
/** The catalog check failed; we cannot claim either way. */
|
|
58
|
+
| "unknown";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* WHY a plugin is in its state — a machine code, so tests and styling can key
|
|
62
|
+
* on it without parsing the human `note`.
|
|
63
|
+
*/
|
|
64
|
+
export type PluginReason =
|
|
65
|
+
/** install: declared by the profile, installed nowhere. */
|
|
66
|
+
| "absent"
|
|
67
|
+
/** install: enabled in a settings file, but no registry row in any scope. */
|
|
68
|
+
| "enabled-not-installed"
|
|
69
|
+
/** unknown: a marketplace we READ does not list it. */
|
|
70
|
+
| "not-offered"
|
|
71
|
+
/** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
|
|
72
|
+
| "marketplace-unread"
|
|
73
|
+
/** unknown: the catalog fetch failed ("latest" pins only). */
|
|
74
|
+
| "catalog-unverified"
|
|
75
|
+
/** current: listed, but its marketplace publishes no version to compare. */
|
|
76
|
+
| "no-published-version"
|
|
77
|
+
/** repair: files changed without a version bump. */
|
|
78
|
+
| "content-stale"
|
|
79
|
+
/** update: a scope is provably behind. */
|
|
80
|
+
| "behind"
|
|
81
|
+
/** current|update: an exact pin differs from what the marketplace offers. */
|
|
82
|
+
| "pin-undeliverable"
|
|
83
|
+
/** current: every installed scope is at (or ahead of) the target. */
|
|
84
|
+
| "up-to-date";
|
|
85
|
+
|
|
86
|
+
export interface PluginUpdateItem {
|
|
87
|
+
pluginId: string;
|
|
88
|
+
/** What the profile pins: an exact version, or "latest". */
|
|
89
|
+
pinned: string;
|
|
90
|
+
/** Highest version installed in any scope, or null when absent. */
|
|
91
|
+
installed: string | null;
|
|
92
|
+
/** Version the marketplace catalog offers, or null when unknown. */
|
|
93
|
+
available: string | null;
|
|
94
|
+
/** Version this item would move to. Null for `current`/`unknown`. */
|
|
95
|
+
target: string | null;
|
|
96
|
+
action: PluginUpdateAction;
|
|
97
|
+
/** Why it is in this state. */
|
|
98
|
+
reason: PluginReason;
|
|
99
|
+
/** Scopes holding an install this action must touch. Empty for a fresh install. */
|
|
100
|
+
scopes: PluginScope[];
|
|
101
|
+
/**
|
|
102
|
+
* The version installed in each scope BEFORE any work, so an apply can tell
|
|
103
|
+
* by reading back whether anything actually moved.
|
|
104
|
+
*/
|
|
105
|
+
beforeVersions: Partial<Record<PluginScope, string>>;
|
|
106
|
+
/**
|
|
107
|
+
* The exact human reason. The CLI prints it and the TUI shows it verbatim, so
|
|
108
|
+
* it is the SAME string in both front ends.
|
|
109
|
+
*/
|
|
110
|
+
note?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Scope → installed version, for the three scopes a PluginInfo carries. */
|
|
114
|
+
function scopeVersions(
|
|
115
|
+
info: PluginInfo | undefined,
|
|
116
|
+
): Array<[PluginScope, string]> {
|
|
117
|
+
if (!info) return [];
|
|
118
|
+
const out: Array<[PluginScope, string]> = [];
|
|
119
|
+
for (const [scope, status] of [
|
|
120
|
+
["user", info.userScope],
|
|
121
|
+
["project", info.projectScope],
|
|
122
|
+
["local", info.localScope],
|
|
123
|
+
] as const) {
|
|
124
|
+
if (status?.version) out.push([scope, status.version]);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The highest version installed across scopes, or null.
|
|
131
|
+
*
|
|
132
|
+
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
133
|
+
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
134
|
+
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
135
|
+
* deterministically is, and every caller only needs *an* installed version.
|
|
136
|
+
*/
|
|
137
|
+
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
138
|
+
const versions = scopeVersions(info).map(([, v]) => v);
|
|
139
|
+
if (versions.length === 0) return null;
|
|
140
|
+
return versions.reduce((a, b) =>
|
|
141
|
+
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Scopes that are strictly BEHIND `target`.
|
|
147
|
+
*
|
|
148
|
+
* Every scope that holds an outdated install is returned, not just "user".
|
|
149
|
+
* Updating one hardcoded scope does not converge: drift is detected from the
|
|
150
|
+
* CURRENT PROJECT's resolved version, so "fixing" user scope reports success
|
|
151
|
+
* and leaves the project row untouched, and the same drift reappears on the
|
|
152
|
+
* next run.
|
|
153
|
+
*
|
|
154
|
+
* This only ever moves FORWARD, and there is deliberately no option to do
|
|
155
|
+
* otherwise. Nothing here can perform a downgrade: `claude plugin install`
|
|
156
|
+
* installs the newest published version and takes no version argument. So a
|
|
157
|
+
* scope ahead of the target is not a problem to fix — it is a state this tool
|
|
158
|
+
* cannot change, and planning against it produces a move that runs, appears to
|
|
159
|
+
* succeed, lands on the same version as before, and is replanned identically on
|
|
160
|
+
* the next run.
|
|
161
|
+
*
|
|
162
|
+
* A "not equal to target" comparison lived here briefly, on the theory that an
|
|
163
|
+
* explicit pin is absolute and moving down to it is as correct as moving up.
|
|
164
|
+
* Both halves were wrong: the pin is not deliverable, and the downgrade is not
|
|
165
|
+
* performable.
|
|
166
|
+
*/
|
|
167
|
+
export function scopesNeeding(
|
|
168
|
+
info: PluginInfo | undefined,
|
|
169
|
+
target: string,
|
|
170
|
+
): PluginScope[] {
|
|
171
|
+
// `isBehind`, not a raw compare: a scope holding a commit sha that differs
|
|
172
|
+
// from the published one IS behind, even though the two carry no ordering.
|
|
173
|
+
// A raw `compareVersions(...) > 0` reported false for that pair and the
|
|
174
|
+
// scope was silently left alone. An unknown (null) is still left alone —
|
|
175
|
+
// moving a scope we cannot rank would be a guess.
|
|
176
|
+
return scopeVersions(info)
|
|
177
|
+
.filter(([, version]) => isBehind(target, version) === true)
|
|
178
|
+
.map(([scope]) => scope);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The "@marketplace" suffix of an id, or null when it has none to reason about. */
|
|
182
|
+
function marketplaceSuffix(id: string): string | null {
|
|
183
|
+
const at = id.indexOf("@");
|
|
184
|
+
if (at < 0) return null;
|
|
185
|
+
const suffix = id.slice(at);
|
|
186
|
+
return suffix.length < 2 ? null : suffix;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The catalog, indexed for the questions the classifier asks of it.
|
|
191
|
+
*
|
|
192
|
+
* "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
|
|
193
|
+
* installed or enabled, but published by no marketplace we read — into the same
|
|
194
|
+
* list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
|
|
195
|
+
* nothing publishes. Treating an orphan as listed is what let an orphan plan as
|
|
196
|
+
* `current`: its `version` is filled in from its own installed version, so the
|
|
197
|
+
* compare found them equal.
|
|
198
|
+
*/
|
|
199
|
+
export interface CatalogIndex {
|
|
200
|
+
get(pluginId: string): PluginInfo | undefined;
|
|
201
|
+
/** In the catalog AND not an orphan: a marketplace we read publishes it. */
|
|
202
|
+
isListed(pluginId: string): boolean;
|
|
203
|
+
/** Some OTHER listed id shares this id's "@marketplace" suffix. False for ids without "@". */
|
|
204
|
+
marketplaceIsKnown(pluginId: string): boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function indexCatalog(
|
|
208
|
+
catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
|
|
209
|
+
): CatalogIndex {
|
|
210
|
+
const byId: ReadonlyMap<string, PluginInfo> =
|
|
211
|
+
catalog instanceof Map
|
|
212
|
+
? catalog
|
|
213
|
+
: new Map((catalog as readonly PluginInfo[]).map((p) => [p.id, p]));
|
|
214
|
+
|
|
215
|
+
// Listed ids per marketplace suffix, counted once. The previous inline check
|
|
216
|
+
// scanned every catalog key for every plugin, which is quadratic in a catalog
|
|
217
|
+
// of ~300 official plugins.
|
|
218
|
+
const listedPerSuffix = new Map<string, number>();
|
|
219
|
+
for (const [id, info] of byId) {
|
|
220
|
+
if (info.isOrphaned) continue;
|
|
221
|
+
const suffix = marketplaceSuffix(id);
|
|
222
|
+
if (suffix)
|
|
223
|
+
listedPerSuffix.set(suffix, (listedPerSuffix.get(suffix) ?? 0) + 1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const isListed = (id: string): boolean => {
|
|
227
|
+
const info = byId.get(id);
|
|
228
|
+
return !!info && !info.isOrphaned;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
get: (id) => byId.get(id),
|
|
233
|
+
isListed,
|
|
234
|
+
marketplaceIsKnown: (id) => {
|
|
235
|
+
const suffix = marketplaceSuffix(id);
|
|
236
|
+
if (!suffix) return false;
|
|
237
|
+
const others =
|
|
238
|
+
(listedPerSuffix.get(suffix) ?? 0) - (isListed(id) ? 1 : 0);
|
|
239
|
+
return others > 0;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** True when a settings file enables the plugin in at least one scope. */
|
|
245
|
+
function enabledAnywhere(info: PluginInfo | undefined): boolean {
|
|
246
|
+
return (
|
|
247
|
+
!!info?.userScope?.enabled ||
|
|
248
|
+
!!info?.projectScope?.enabled ||
|
|
249
|
+
!!info?.localScope?.enabled
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* THE plugin classifier. Pure.
|
|
255
|
+
*
|
|
256
|
+
* The rules are evaluated in order and the first match wins. Each branch below
|
|
257
|
+
* returns, so reading top to bottom is reading the precedence.
|
|
258
|
+
*
|
|
259
|
+
* @param pluginId the plugin
|
|
260
|
+
* @param pin what it is judged against: an exact version, or "latest"
|
|
261
|
+
* @param index the catalog — see {@link indexCatalog}
|
|
262
|
+
*/
|
|
263
|
+
export function classifyPlugin(
|
|
264
|
+
pluginId: string,
|
|
265
|
+
pin: string,
|
|
266
|
+
index: CatalogIndex,
|
|
267
|
+
): PluginUpdateItem {
|
|
268
|
+
const info = index.get(pluginId);
|
|
269
|
+
const listed = index.isListed(pluginId);
|
|
270
|
+
const installed = highestInstalled(info);
|
|
271
|
+
// An orphan's `version` is its own installed version (or a stale local
|
|
272
|
+
// clone's), not something a marketplace we read offers — so it is not
|
|
273
|
+
// "available" from anywhere.
|
|
274
|
+
const available = listed ? (info?.version ?? null) : null;
|
|
275
|
+
|
|
276
|
+
const base = {
|
|
277
|
+
pluginId,
|
|
278
|
+
pinned: pin,
|
|
279
|
+
installed,
|
|
280
|
+
available,
|
|
281
|
+
beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
|
|
282
|
+
Record<PluginScope, string>
|
|
283
|
+
>,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Declared by the profile, installed nowhere, and its marketplace IS
|
|
287
|
+
// known to us but does not list it. That pin names a plugin that cannot
|
|
288
|
+
// be installed, and saying so is the whole point of this branch.
|
|
289
|
+
//
|
|
290
|
+
// Measured on the reporter's machine: mnemex's profile pinned
|
|
291
|
+
// `agentdev@magus` (retired) and `seo@magus` (moved to magus-marketing),
|
|
292
|
+
// and every run reported "3 install" while two of the three could never
|
|
293
|
+
// succeed — `claude plugin install` had nothing to fetch. Applied, failed,
|
|
294
|
+
// replanned identically, forever, with no diagnostic.
|
|
295
|
+
//
|
|
296
|
+
// "Not offered" is CATALOG MEMBERSHIP, not a missing version. Keying this
|
|
297
|
+
// on `available === null` was wrong twice over, and the second way was
|
|
298
|
+
// worse than the bug it replaced:
|
|
299
|
+
//
|
|
300
|
+
// - 278 of the 292 plugins in `claude-plugins-official` declare no
|
|
301
|
+
// version at all — `feature-dev` among them. They are listed, they are
|
|
302
|
+
// installable, and `available` is null for every one. Treating that as
|
|
303
|
+
// "not offered" made a profile pin for any of them plan `unknown` and
|
|
304
|
+
// never install. Measured on the reporting machine.
|
|
305
|
+
// - An INSTALLED plugin whose catalog version is unreadable is handled
|
|
306
|
+
// further down against what is on disk. Hence `!installed` here.
|
|
307
|
+
//
|
|
308
|
+
// Listing answers the real question directly. The remaining trap is that a
|
|
309
|
+
// marketplace this machine has not registered yet contributes NO entries, so
|
|
310
|
+
// its plugins are also absent — but installing one is exactly what registers
|
|
311
|
+
// the marketplace. `marketplaceIsKnown` separates the two: holding other
|
|
312
|
+
// LISTED ids from that marketplace proves we genuinely read its catalog, so
|
|
313
|
+
// absence is an answer; holding none proves nothing.
|
|
314
|
+
if (!listed && !installed && index.marketplaceIsKnown(pluginId)) {
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
target: null,
|
|
318
|
+
action: "unknown",
|
|
319
|
+
reason: "not-offered",
|
|
320
|
+
scopes: [],
|
|
321
|
+
note: "not offered by any configured marketplace",
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Not installed anywhere — the "checked but not installed" case.
|
|
326
|
+
//
|
|
327
|
+
// Nothing is installed in any scope here, so "enabled somewhere" is exactly
|
|
328
|
+
// "enabled but not installed". It is tested directly rather than through
|
|
329
|
+
// `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
|
|
330
|
+
// a settings key would otherwise read as `absent`, and the TUI renders
|
|
331
|
+
// `absent` as "the profile declares it", which an undeclared orphan does not.
|
|
332
|
+
if (!installed) {
|
|
333
|
+
const brokenEnable = enabledAnywhere(info);
|
|
334
|
+
return {
|
|
335
|
+
...base,
|
|
336
|
+
target: pin === "latest" ? available : pin,
|
|
337
|
+
action: "install",
|
|
338
|
+
reason: brokenEnable ? "enabled-not-installed" : "absent",
|
|
339
|
+
scopes: [],
|
|
340
|
+
...(brokenEnable
|
|
341
|
+
? { note: "enabled in settings, but no files on disk" }
|
|
342
|
+
: {}),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Installed, but no marketplace we read lists it — an orphan.
|
|
347
|
+
//
|
|
348
|
+
// Planning this against its own installed version (which is what an
|
|
349
|
+
// orphan's `version` holds) found the two equal and reported `current`,
|
|
350
|
+
// while the TUI rendered the same plugin red as "deprecated". Neither is
|
|
351
|
+
// the fact. What is known is one of three things, and each gets its own
|
|
352
|
+
// text: it moved to a marketplace that does publish it; a marketplace we
|
|
353
|
+
// did read no longer lists it; or its marketplace contributed nothing we
|
|
354
|
+
// could read, so its absence proves nothing either way.
|
|
355
|
+
if (!listed) {
|
|
356
|
+
const orphan = {
|
|
357
|
+
...base,
|
|
358
|
+
target: null,
|
|
359
|
+
action: "unknown" as const,
|
|
360
|
+
scopes: [],
|
|
361
|
+
};
|
|
362
|
+
if (index.marketplaceIsKnown(pluginId)) {
|
|
363
|
+
return {
|
|
364
|
+
...orphan,
|
|
365
|
+
reason: "not-offered",
|
|
366
|
+
note: "not offered by any configured marketplace",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const mp =
|
|
370
|
+
info?.marketplace || marketplaceSuffix(pluginId)?.slice(1) || pluginId;
|
|
371
|
+
return {
|
|
372
|
+
...orphan,
|
|
373
|
+
reason: "marketplace-unread",
|
|
374
|
+
note: `its marketplace ${mp} was not read, so whether it still offers this plugin is unknown`,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// An exact pin is planned against what can ACTUALLY be delivered.
|
|
379
|
+
//
|
|
380
|
+
// This is dependency management, not a package manager: there is no
|
|
381
|
+
// resolver and no version negotiation. `claude plugin install` takes no
|
|
382
|
+
// version — it fetches whatever the marketplace publishes now — so a pin
|
|
383
|
+
// naming anything else is a request nothing can satisfy.
|
|
384
|
+
//
|
|
385
|
+
// Planning toward the pin regardless is an infinite loop with a progress
|
|
386
|
+
// bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
|
|
387
|
+
// next run, and fail `--check` every time in between. Targeting the
|
|
388
|
+
// deliverable version converges, and the note carries the shortfall so it
|
|
389
|
+
// is recorded rather than hidden. `install --check` + `strictVersions`
|
|
390
|
+
// remains the place that GATES on pin drift; this command reports it.
|
|
391
|
+
//
|
|
392
|
+
// `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
|
|
393
|
+
// deliverable version is left alone rather than downgraded — the same rule
|
|
394
|
+
// as the "latest" path below, because in both cases the target is what the
|
|
395
|
+
// marketplace currently publishes.
|
|
396
|
+
//
|
|
397
|
+
// The decision comes from the PER-SCOPE versions, never from `installed`.
|
|
398
|
+
// `installed` is the highest across scopes, so comparing it to the target
|
|
399
|
+
// hides the case that matters most: user scope at the target and project
|
|
400
|
+
// scope behind it reads as "current" while the project — the scope that
|
|
401
|
+
// actually loads for this repo — stays stale.
|
|
402
|
+
if (pin !== "latest") {
|
|
403
|
+
const deliverable = available ?? pin;
|
|
404
|
+
const needing = scopesNeeding(info, deliverable);
|
|
405
|
+
|
|
406
|
+
// A scope that cannot be ordered against the deliverable contributes
|
|
407
|
+
// nothing to `needing` and lands on `current`. That is the decision: if we
|
|
408
|
+
// cannot compare, assume what is installed is the latest. See the "latest"
|
|
409
|
+
// path below for the same rule and what it costs.
|
|
410
|
+
|
|
411
|
+
const undeliverable = available !== null && available !== pin;
|
|
412
|
+
return {
|
|
413
|
+
...base,
|
|
414
|
+
target: deliverable,
|
|
415
|
+
action: needing.length === 0 ? "current" : "update",
|
|
416
|
+
reason: undeliverable
|
|
417
|
+
? "pin-undeliverable"
|
|
418
|
+
: needing.length === 0
|
|
419
|
+
? "up-to-date"
|
|
420
|
+
: "behind",
|
|
421
|
+
scopes: needing,
|
|
422
|
+
...(undeliverable
|
|
423
|
+
? {
|
|
424
|
+
note: `manifest pins ${pin}; the marketplace offers only ${available}`,
|
|
425
|
+
}
|
|
426
|
+
: {}),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// "latest" — and the catalog is the only source for what that means.
|
|
431
|
+
if (info?.updateCheckFailed) {
|
|
432
|
+
return {
|
|
433
|
+
...base,
|
|
434
|
+
target: null,
|
|
435
|
+
action: "unknown",
|
|
436
|
+
reason: "catalog-unverified",
|
|
437
|
+
scopes: [],
|
|
438
|
+
note:
|
|
439
|
+
info.updateCheckFailure?.detail ??
|
|
440
|
+
"catalog fetch failed — cannot tell whether a newer version exists",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Same version, changed files. There is no version bump to detect, so a
|
|
445
|
+
// plain install is a no-op ("Plugin is already installed") and only
|
|
446
|
+
// uninstall+install refreshes the content.
|
|
447
|
+
if (info?.contentStale) {
|
|
448
|
+
return {
|
|
449
|
+
...base,
|
|
450
|
+
target: installed,
|
|
451
|
+
action: "repair",
|
|
452
|
+
reason: "content-stale",
|
|
453
|
+
scopes: scopeVersions(info).map(([scope]) => scope),
|
|
454
|
+
note: "files changed without a version bump",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Installed and listed, with nothing to compare against — still published,
|
|
459
|
+
// still installable, we simply have no number. 278 of the 292 plugins in
|
|
460
|
+
// `claude-plugins-official` are in this state.
|
|
461
|
+
//
|
|
462
|
+
// By decision this is CURRENT: if we cannot compare, assume what is installed
|
|
463
|
+
// is the latest. It was `unknown`, which put a red badge and a sentence on a
|
|
464
|
+
// quarter of the report for a fact nobody can act on. The `reason` is kept so
|
|
465
|
+
// a caller can still tell this apart from a verified match; the note is not,
|
|
466
|
+
// because there is nothing to say.
|
|
467
|
+
if (!available) {
|
|
468
|
+
return {
|
|
469
|
+
...base,
|
|
470
|
+
target: installed,
|
|
471
|
+
action: "current",
|
|
472
|
+
reason: "no-published-version",
|
|
473
|
+
scopes: [],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Again per-scope, and only ever forward.
|
|
478
|
+
const needing = scopesNeeding(info, available);
|
|
479
|
+
|
|
480
|
+
// A scope whose version cannot be ordered against the catalog contributes
|
|
481
|
+
// nothing to `needing`, so it lands on `current`. That is the decision, not
|
|
482
|
+
// an oversight: if we cannot compare, assume the installed one is the latest.
|
|
483
|
+
//
|
|
484
|
+
// What it costs, stated plainly: such a plugin can never report an update,
|
|
485
|
+
// and reads exactly like a verified-current one. What it buys: `--check` can
|
|
486
|
+
// pass at all. It used to fail forever on the 21 registry rows carrying the
|
|
487
|
+
// literal string "unknown" as their version, across 8
|
|
488
|
+
// `@claude-plugins-official` ids — a gate nothing could make green.
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
...base,
|
|
492
|
+
target: needing.length === 0 ? installed : available,
|
|
493
|
+
action: needing.length === 0 ? "current" : "update",
|
|
494
|
+
reason: needing.length === 0 ? "up-to-date" : "behind",
|
|
495
|
+
scopes: needing,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Decide what to do with every plugin the profile declares.
|
|
501
|
+
*
|
|
502
|
+
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
503
|
+
* @param catalog getAvailablePlugins() output, keyed by plugin id
|
|
504
|
+
*/
|
|
505
|
+
export function planPluginUpdates(
|
|
506
|
+
pinned: Record<string, string>,
|
|
507
|
+
catalog: Map<string, PluginInfo>,
|
|
508
|
+
): PluginUpdateItem[] {
|
|
509
|
+
const index = indexCatalog(catalog);
|
|
510
|
+
return Object.entries(pinned)
|
|
511
|
+
.map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
|
|
512
|
+
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The pin a TUI row is judged against.
|
|
517
|
+
*
|
|
518
|
+
* 1. The active profile's pin, when it declares the plugin — the SAME value
|
|
519
|
+
* `magus update` plans with, which is what makes the two agree.
|
|
520
|
+
* 2. Otherwise "latest", when the plugin is installed in any scope or enabled
|
|
521
|
+
* in one — what the TUI's own update actions have always planned with, so
|
|
522
|
+
* the row and the action agree.
|
|
523
|
+
* 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
|
|
524
|
+
* gets no status at all. `install` means "the profile wants it", and an
|
|
525
|
+
* undeclared plugin carries no such claim.
|
|
526
|
+
*
|
|
527
|
+
* A consequence the renderer relies on: reason `absent` can only come from
|
|
528
|
+
* rule 1, because rule 2 never assigns a pin to a row that is neither installed
|
|
529
|
+
* nor enabled.
|
|
530
|
+
*/
|
|
531
|
+
export function pinForRow(
|
|
532
|
+
info: PluginInfo,
|
|
533
|
+
pins: Readonly<Record<string, string>>,
|
|
534
|
+
): string | null {
|
|
535
|
+
if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
|
|
536
|
+
if (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* One status per row that has a pin. Rows whose {@link pinForRow} is null are
|
|
542
|
+
* absent from the map.
|
|
543
|
+
*
|
|
544
|
+
* Computed once per data/pins change, and the same map feeds every consumer —
|
|
545
|
+
* so the row, the detail panel, the header and the scope picker cannot disagree.
|
|
546
|
+
*/
|
|
547
|
+
export function classifyPluginRows(
|
|
548
|
+
plugins: readonly PluginInfo[],
|
|
549
|
+
pins: Readonly<Record<string, string>>,
|
|
550
|
+
): Map<string, PluginUpdateItem> {
|
|
551
|
+
const index = indexCatalog(plugins);
|
|
552
|
+
const out = new Map<string, PluginUpdateItem>();
|
|
553
|
+
for (const info of plugins) {
|
|
554
|
+
const pin = pinForRow(info, pins);
|
|
555
|
+
if (pin === null) continue;
|
|
556
|
+
out.set(info.id, classifyPlugin(info.id, pin, index));
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export interface PluginStateCounts {
|
|
562
|
+
install: number;
|
|
563
|
+
update: number;
|
|
564
|
+
repair: number;
|
|
565
|
+
current: number;
|
|
566
|
+
unknown: number;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Counts per action. One implementation, so a header cannot recount differently. */
|
|
570
|
+
export function countPluginStates(
|
|
571
|
+
items: Iterable<PluginUpdateItem>,
|
|
572
|
+
): PluginStateCounts {
|
|
573
|
+
const counts: PluginStateCounts = {
|
|
574
|
+
install: 0,
|
|
575
|
+
update: 0,
|
|
576
|
+
repair: 0,
|
|
577
|
+
current: 0,
|
|
578
|
+
unknown: 0,
|
|
579
|
+
};
|
|
580
|
+
for (const item of items) counts[item.action]++;
|
|
581
|
+
return counts;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* The scope picker's one question: would advancing THIS scope be an update?
|
|
586
|
+
*
|
|
587
|
+
* Only a provable `update` that touches the scope says yes. A scope ahead of the
|
|
588
|
+
* catalog, one that cannot be ordered, and a `repair` all say no — `claude
|
|
589
|
+
* plugin install` on the same version is a no-op, so offering "update" there
|
|
590
|
+
* runs something that changes nothing.
|
|
591
|
+
*/
|
|
592
|
+
export function scopeIsBehind(
|
|
593
|
+
status: PluginUpdateItem | null | undefined,
|
|
594
|
+
scope: PluginScope,
|
|
595
|
+
): boolean {
|
|
596
|
+
return status?.action === "update" && status.scopes.includes(scope);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Binaries ─────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* A tool's action is exactly its state from `classifyTool` — the same word the
|
|
603
|
+
* TUI's CLI Tools screen shows for the same tool.
|
|
604
|
+
*/
|
|
605
|
+
export type BinUpdateAction = ToolState;
|
|
606
|
+
|
|
607
|
+
export interface BinUpdateItem {
|
|
608
|
+
name: string;
|
|
609
|
+
action: BinUpdateAction;
|
|
610
|
+
/** Why it is in this state. */
|
|
611
|
+
reason: ToolReason;
|
|
612
|
+
/** The exact human reason; the same string the TUI shows. */
|
|
613
|
+
note?: string;
|
|
614
|
+
/**
|
|
615
|
+
* Set when the upgrade will take the tool over from another installer —
|
|
616
|
+
* {@link ToolStatus.adopted}. The row must say so: it is an ordinary
|
|
617
|
+
* `upgrade` otherwise, and the owner changing is not something to discover
|
|
618
|
+
* afterwards.
|
|
619
|
+
*/
|
|
620
|
+
adopted?: string;
|
|
621
|
+
/** The exact pin, when the profile declares one. */
|
|
622
|
+
version?: string;
|
|
623
|
+
/** The version PATH runs, or null when absent / unreadable. */
|
|
624
|
+
installed: string | null;
|
|
625
|
+
/** The newest version its installer offers, when known. */
|
|
626
|
+
latest: string | null;
|
|
627
|
+
/** The version an install/upgrade aims at, when known. */
|
|
628
|
+
target: string | null;
|
|
629
|
+
sources: string[];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Plan binary work from probe facts, through {@link classifyTool}.
|
|
634
|
+
*
|
|
635
|
+
* This used to plan from a presence check alone: present-and-unpinned was an
|
|
636
|
+
* UPGRADE, every run, because nothing could say whether the tool was behind.
|
|
637
|
+
* `brew upgrade tmux` on a current tmux, `uv tool install --force --upgrade`
|
|
638
|
+
* on a current browser-use — each "succeeded", changed nothing, and was
|
|
639
|
+
* planned identically the next time. Now an upgrade is planned only when the
|
|
640
|
+
* installer that owns the running binary says it is behind (or its registry
|
|
641
|
+
* does), and a tool whose state cannot be established is `unknown`, which is
|
|
642
|
+
* shown and never applied.
|
|
643
|
+
*
|
|
644
|
+
* Facts are matched by name. A bin with no facts entry was never probed, and
|
|
645
|
+
* that is `unknown` too — "not checked" is not "fine".
|
|
646
|
+
*/
|
|
647
|
+
export function planBinUpdates(
|
|
648
|
+
bins: ResolvedBin[],
|
|
649
|
+
facts: readonly ToolFacts[],
|
|
650
|
+
): BinUpdateItem[] {
|
|
651
|
+
const byName = new Map(facts.map((f) => [f.name, f]));
|
|
652
|
+
return bins.map((bin) => {
|
|
653
|
+
const f = byName.get(bin.name);
|
|
654
|
+
const status: ToolStatus = f
|
|
655
|
+
? classifyTool(bin, f)
|
|
656
|
+
: {
|
|
657
|
+
name: bin.name,
|
|
658
|
+
state: "unknown",
|
|
659
|
+
reason: "not-probed",
|
|
660
|
+
note: "not probed",
|
|
661
|
+
installed: null,
|
|
662
|
+
latest: null,
|
|
663
|
+
target: null,
|
|
664
|
+
};
|
|
665
|
+
const item: BinUpdateItem = {
|
|
666
|
+
name: bin.name,
|
|
667
|
+
action: status.state,
|
|
668
|
+
reason: status.reason,
|
|
669
|
+
installed: status.installed,
|
|
670
|
+
latest: status.latest,
|
|
671
|
+
target: status.target,
|
|
672
|
+
sources: bin.sources,
|
|
673
|
+
};
|
|
674
|
+
if (status.note) item.note = status.note;
|
|
675
|
+
if (status.adopted) item.adopted = status.adopted;
|
|
676
|
+
if (bin.version) item.version = bin.version;
|
|
677
|
+
return item;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Whether a bin item is WORK: an install or an upgrade.
|
|
683
|
+
*
|
|
684
|
+
* The one predicate `applyBins`, `planWorkItems` and `planHasWork` share. The
|
|
685
|
+
* apply loop used to skip only `current` and run everything else, so the day an
|
|
686
|
+
* `unknown` existed it would have been applied — "cannot tell" executed as an
|
|
687
|
+
* upgrade. One predicate means the three cannot disagree about that.
|
|
688
|
+
*/
|
|
689
|
+
export function binNeedsWork(item: BinUpdateItem): boolean {
|
|
690
|
+
return item.action === "install" || item.action === "upgrade";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Skills ───────────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
export interface SkillUpdateItem {
|
|
696
|
+
name: string;
|
|
697
|
+
ref: ProfileSkillRef;
|
|
698
|
+
action: "install" | "refresh";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Plan skill work.
|
|
703
|
+
*
|
|
704
|
+
* A profile's skill refs carry no content hash, so "is this one behind?" is not
|
|
705
|
+
* answerable without fetching every file. Skills are small and the fetch is the
|
|
706
|
+
* same call either way, so `update` re-fetches the installed ones instead of
|
|
707
|
+
* guessing. The distinction is kept in the REPORT — a user needs to see which
|
|
708
|
+
* skills were newly added — not in the work.
|
|
709
|
+
*/
|
|
710
|
+
export function planSkillUpdates(
|
|
711
|
+
skills: ProfileSkillRef[],
|
|
712
|
+
installedNames: ReadonlySet<string>,
|
|
713
|
+
): SkillUpdateItem[] {
|
|
714
|
+
return skills.map((ref) => ({
|
|
715
|
+
name: ref.name,
|
|
716
|
+
ref,
|
|
717
|
+
action: installedNames.has(ref.name) ? "refresh" : "install",
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── Summary ──────────────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
export interface UpdatePlan {
|
|
724
|
+
profileId: string;
|
|
725
|
+
plugins: PluginUpdateItem[];
|
|
726
|
+
bins: BinUpdateItem[];
|
|
727
|
+
skills: SkillUpdateItem[];
|
|
728
|
+
/**
|
|
729
|
+
* The full requirement behind each bin item, by name. The plan items carry
|
|
730
|
+
* only the decision; the installer command needs `via`/`package`/`formula`,
|
|
731
|
+
* and re-deriving those from a name would guess.
|
|
732
|
+
*/
|
|
733
|
+
binSpecs: Map<string, ResolvedBin>;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* True when applying the plan would do any work at all — including work that is
|
|
738
|
+
* only ever best-effort, like re-fetching an already-installed skill.
|
|
739
|
+
*
|
|
740
|
+
* Drives the "nothing to do" early exit. NOT the `--check` gate: see
|
|
741
|
+
* {@link planIsBehind}.
|
|
742
|
+
*/
|
|
743
|
+
export function planHasWork(plan: UpdatePlan): boolean {
|
|
744
|
+
return (
|
|
745
|
+
plan.plugins.some((p) => p.action !== "current") ||
|
|
746
|
+
plan.bins.some(binNeedsWork) ||
|
|
747
|
+
plan.skills.length > 0
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* True when something is actually MISSING or KNOWN TO BE BEHIND. This is what
|
|
753
|
+
* `--check` gates on, and it is deliberately narrower than {@link planHasWork}.
|
|
754
|
+
*
|
|
755
|
+
* The distinction exists because a skill is re-run every time without that
|
|
756
|
+
* meaning anything is wrong: its manifest ref carries no content hash, so it is
|
|
757
|
+
* re-fetched rather than compared. Counting that as drift made `--check` exit 1
|
|
758
|
+
* forever for any profile that declared a single skill — a gate that can never
|
|
759
|
+
* pass tells you nothing, and would have been switched off in CI within a week.
|
|
760
|
+
*
|
|
761
|
+
* A bin `upgrade` DOES count now. It used to be excluded because an unpinned
|
|
762
|
+
* binary had no version probe and "upgrade" meant "re-run the installer"; it
|
|
763
|
+
* now means the owning installer says the tool is behind, which is drift.
|
|
764
|
+
*
|
|
765
|
+
* NEITHER an `unknown` plugin NOR an `unknown` tool counts. "We could not check
|
|
766
|
+
* this" is not evidence of drift, and most unknowns are structural and
|
|
767
|
+
* permanent rather than transient: a plugin whose marketplace publishes no
|
|
768
|
+
* version (278 of the official ones), a tool on PATH but installed by a
|
|
769
|
+
* different npm, `go` absent, a private registry. A gate that exits 1 on those
|
|
770
|
+
* can never pass, so it protects nothing and is switched off. The cost is that
|
|
771
|
+
* an item whose check merely FAILED this run — a rate-limited catalog fetch, a
|
|
772
|
+
* timed-out probe — also passes, so the exit code alone must never be read as
|
|
773
|
+
* "everything verified": the caller prints how many items went unchecked
|
|
774
|
+
* (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
|
|
775
|
+
* `cli/update-view.ts`), and every unknown row carries its own reason above.
|
|
776
|
+
*
|
|
777
|
+
* Both unknowns are listed positively rather than as `!== "current"`, so a new
|
|
778
|
+
* action has to be classified here deliberately instead of defaulting into the
|
|
779
|
+
* gate.
|
|
780
|
+
*/
|
|
781
|
+
export function planIsBehind(plan: UpdatePlan): boolean {
|
|
782
|
+
return (
|
|
783
|
+
plan.plugins.some(
|
|
784
|
+
(p) =>
|
|
785
|
+
p.action === "install" ||
|
|
786
|
+
p.action === "update" ||
|
|
787
|
+
p.action === "repair",
|
|
788
|
+
) ||
|
|
789
|
+
plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
|
|
790
|
+
plan.skills.some((s) => s.action === "install")
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Counts for the closing summary line, in a stable order. */
|
|
795
|
+
export function summarizePlan(plan: UpdatePlan): {
|
|
796
|
+
installed: number;
|
|
797
|
+
updated: number;
|
|
798
|
+
repaired: number;
|
|
799
|
+
current: number;
|
|
800
|
+
/** unknownPlugins + unknownTools. */
|
|
801
|
+
unknown: number;
|
|
802
|
+
unknownPlugins: number;
|
|
803
|
+
unknownTools: number;
|
|
804
|
+
} {
|
|
805
|
+
const count = (a: PluginUpdateAction) =>
|
|
806
|
+
plan.plugins.filter((p) => p.action === a).length;
|
|
807
|
+
const bins = (a: BinUpdateAction) =>
|
|
808
|
+
plan.bins.filter((b) => b.action === a).length;
|
|
809
|
+
const unknownPlugins = count("unknown");
|
|
810
|
+
const unknownTools = bins("unknown");
|
|
811
|
+
return {
|
|
812
|
+
installed: count("install") + bins("install"),
|
|
813
|
+
updated: count("update") + bins("upgrade"),
|
|
814
|
+
repaired: count("repair"),
|
|
815
|
+
current: count("current") + bins("current"),
|
|
816
|
+
unknown: unknownPlugins + unknownTools,
|
|
817
|
+
unknownPlugins,
|
|
818
|
+
unknownTools,
|
|
819
|
+
};
|
|
820
|
+
}
|