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,315 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import fs from "fs-extra";
|
|
4
|
+
import { DEFAULT_PRESET, findPreset } from "../data/models-presets.js";
|
|
5
|
+
import type { ModelsStatus } from "../services/models-core.js";
|
|
6
|
+
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
7
|
+
import type { AppAction, AppState, ModelsSnapshot } from "../ui/state/types.js";
|
|
8
|
+
import { asyncValue } from "../ui/state/types.js";
|
|
9
|
+
|
|
10
|
+
function run(actions: AppAction[], from: AppState = initialState): AppState {
|
|
11
|
+
return actions.reduce(appReducer, from);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
|
|
15
|
+
return { state: "off", preset: null, drift: [], warnings: [], ...over };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
|
|
19
|
+
return {
|
|
20
|
+
status: status(),
|
|
21
|
+
config: null,
|
|
22
|
+
path: "/x/.claude/models.json",
|
|
23
|
+
...over,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Drop `//` line comments and block comments, so a source guard matches code only. */
|
|
28
|
+
function stripComments(source: string): string {
|
|
29
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Navigating away and back is what unmounts the screen. */
|
|
33
|
+
const roundTrip: AppAction[] = [
|
|
34
|
+
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
35
|
+
{ type: "NAVIGATE", route: { screen: "models" } },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
describe("models screen initial state", () => {
|
|
39
|
+
test("starts idle, unfiltered, and with no message", () => {
|
|
40
|
+
expect(initialState.models.data.status).toBe("idle");
|
|
41
|
+
expect(initialState.models.searchQuery).toBe("");
|
|
42
|
+
expect(initialState.models.status).toBeNull();
|
|
43
|
+
expect(initialState.models.isApplying).toBe(false);
|
|
44
|
+
// The mate slots are not drawn until something says they can be served. Starting
|
|
45
|
+
// true would put three rows on screen before anything had been asked.
|
|
46
|
+
expect(initialState.models.matesAvailable).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
|
|
50
|
+
const state = run([
|
|
51
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
52
|
+
...roundTrip,
|
|
53
|
+
]);
|
|
54
|
+
expect(state.currentRoute.screen).toBe("models");
|
|
55
|
+
expect(state.models.data.status).toBe("success");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("models search state", () => {
|
|
60
|
+
test("appending builds up the query one character at a time", () => {
|
|
61
|
+
// The regression this guards: computing `searchQuery + char` in the screen
|
|
62
|
+
// reads a value captured at render time, so keystrokes arriving faster
|
|
63
|
+
// than React re-renders all start from the same stale string.
|
|
64
|
+
const state = run([
|
|
65
|
+
{ type: "MODELS_SEARCH_APPEND", char: "o" },
|
|
66
|
+
{ type: "MODELS_SEARCH_APPEND", char: "p" },
|
|
67
|
+
{ type: "MODELS_SEARCH_APPEND", char: "u" },
|
|
68
|
+
{ type: "MODELS_SEARCH_APPEND", char: "s" },
|
|
69
|
+
]);
|
|
70
|
+
expect(state.models.searchQuery).toBe("opus");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("appending resets the cursor so it cannot sit past the filtered list", () => {
|
|
74
|
+
const state = run([
|
|
75
|
+
{ type: "MODELS_SELECT", index: 4 },
|
|
76
|
+
{ type: "MODELS_SEARCH_APPEND", char: "h" },
|
|
77
|
+
]);
|
|
78
|
+
expect(state.models.selectedIndex).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("backspace removes one character at a time", () => {
|
|
82
|
+
const typed = run([
|
|
83
|
+
{ type: "MODELS_SEARCH_APPEND", char: "a" },
|
|
84
|
+
{ type: "MODELS_SEARCH_APPEND", char: "b" },
|
|
85
|
+
{ type: "MODELS_SEARCH_APPEND", char: "c" },
|
|
86
|
+
]);
|
|
87
|
+
expect(
|
|
88
|
+
run([{ type: "MODELS_SEARCH_BACKSPACE" }], typed).models.searchQuery,
|
|
89
|
+
).toBe("ab");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("backspace on an empty query is harmless", () => {
|
|
93
|
+
expect(run([{ type: "MODELS_SEARCH_BACKSPACE" }]).models.searchQuery).toBe(
|
|
94
|
+
"",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("clearing the filter is one action, and it leaves the data alone", () => {
|
|
99
|
+
const filtered = run([
|
|
100
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
101
|
+
{ type: "MODELS_SEARCH_APPEND", char: "z" },
|
|
102
|
+
]);
|
|
103
|
+
const cleared = run([{ type: "MODELS_SET_SEARCH", query: "" }], filtered);
|
|
104
|
+
expect(cleared.models.searchQuery).toBe("");
|
|
105
|
+
expect(cleared.models.data.status).toBe("success");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("models data state", () => {
|
|
110
|
+
test("a reload keeps the last snapshot on screen", () => {
|
|
111
|
+
// Reading the routing scans session transcripts to resolve the live model
|
|
112
|
+
// id behind each alias, so a reload is not instant. Without `previous` the
|
|
113
|
+
// list blanks to "Reading…" every time — including on every tab switch,
|
|
114
|
+
// because the router unmounts the screen and the effect refetches.
|
|
115
|
+
const loaded = run([
|
|
116
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot({ path: "/a" }) },
|
|
117
|
+
{ type: "MODELS_DATA_LOADING" },
|
|
118
|
+
]);
|
|
119
|
+
expect(loaded.models.data.status).toBe("loading");
|
|
120
|
+
expect(asyncValue(loaded.models.data)?.path).toBe("/a");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("a FIRST load has nothing to carry, and says so", () => {
|
|
124
|
+
const loading = run([{ type: "MODELS_DATA_LOADING" }]);
|
|
125
|
+
expect(asyncValue(loading.models.data)).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("success stores the whole snapshot, config included", () => {
|
|
129
|
+
const config = findPreset(DEFAULT_PRESET) ?? null;
|
|
130
|
+
const state = run([
|
|
131
|
+
{
|
|
132
|
+
type: "MODELS_DATA_SUCCESS",
|
|
133
|
+
snapshot: snapshot({
|
|
134
|
+
status: status({ state: "on", preset: DEFAULT_PRESET }),
|
|
135
|
+
config,
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
const value = asyncValue(state.models.data);
|
|
140
|
+
expect(value?.status.state).toBe("on");
|
|
141
|
+
expect(value?.config?.preset).toBe(DEFAULT_PRESET);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("an error replaces the data rather than hiding behind stale rows", () => {
|
|
145
|
+
const state = run([
|
|
146
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
147
|
+
{ type: "MODELS_DATA_ERROR", error: new Error("unreadable") },
|
|
148
|
+
]);
|
|
149
|
+
expect(state.models.data.status).toBe("error");
|
|
150
|
+
expect(asyncValue(state.models.data)).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("models status line", () => {
|
|
155
|
+
test("survives the screen unmounting, because it lives in app state", () => {
|
|
156
|
+
// `Router` swaps the component type on a tab change, so a message held in
|
|
157
|
+
// the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
|
|
158
|
+
// the only record of what the last apply did.
|
|
159
|
+
const messaged = run([
|
|
160
|
+
{
|
|
161
|
+
type: "MODELS_STATUS_SET",
|
|
162
|
+
status: { text: "Routed opus-lead", tone: "success" },
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
165
|
+
expect(run(roundTrip, messaged).models.status?.text).toBe(
|
|
166
|
+
"Routed opus-lead",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("is replaced by the next message, not appended to", () => {
|
|
171
|
+
const state = run([
|
|
172
|
+
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
173
|
+
{ type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
|
|
174
|
+
]);
|
|
175
|
+
expect(state.models.status).toEqual({ text: "two", tone: "error" });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("clearing removes it", () => {
|
|
179
|
+
const state = run([
|
|
180
|
+
{ type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
|
|
181
|
+
{ type: "MODELS_STATUS_CLEAR" },
|
|
182
|
+
]);
|
|
183
|
+
expect(state.models.status).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("apply-in-flight state", () => {
|
|
188
|
+
test("survives the screen unmounting", () => {
|
|
189
|
+
// An apply rewrites the profile, re-materializes it and registers a hook.
|
|
190
|
+
// Held locally, the flag died on a tab switch and a returning user could
|
|
191
|
+
// launch a second write on top of the first.
|
|
192
|
+
const applying = run([{ type: "MODELS_APPLY_START" }]);
|
|
193
|
+
expect(applying.models.isApplying).toBe(true);
|
|
194
|
+
expect(run(roundTrip, applying).models.isApplying).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("clears when the write finishes", () => {
|
|
198
|
+
expect(
|
|
199
|
+
run([{ type: "MODELS_APPLY_START" }, { type: "MODELS_APPLY_END" }]).models
|
|
200
|
+
.isApplying,
|
|
201
|
+
).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("the mate slots' availability", () => {
|
|
206
|
+
test("survives the screen unmounting, so the lookup happens once a session", () => {
|
|
207
|
+
// Unlike the two file reads this screen does on mount, this answer costs a
|
|
208
|
+
// marketplace resolution with network timeouts behind it. Held locally it would die
|
|
209
|
+
// on every tab switch and be paid for again on every return — for something that
|
|
210
|
+
// cannot change while the TUI is open.
|
|
211
|
+
const available = run([
|
|
212
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
213
|
+
]);
|
|
214
|
+
expect(available.models.matesAvailable).toBe(true);
|
|
215
|
+
expect(run(roundTrip, available).models.matesAvailable).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a false answer is recorded, not treated as no answer", () => {
|
|
219
|
+
// "Not installed" and "never asked" render the same, and that is deliberate — but
|
|
220
|
+
// the reducer still has to hold the difference, or a later dispatch could not undo it.
|
|
221
|
+
const state = run([
|
|
222
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
223
|
+
{ type: "MODELS_MATES_AVAILABLE", available: false },
|
|
224
|
+
]);
|
|
225
|
+
expect(state.models.matesAvailable).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("it touches no other part of the models state", () => {
|
|
229
|
+
const before = run([
|
|
230
|
+
{ type: "MODELS_SELECT", index: 2 },
|
|
231
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
232
|
+
]);
|
|
233
|
+
const after = run(
|
|
234
|
+
[{ type: "MODELS_MATES_AVAILABLE", available: true }],
|
|
235
|
+
before,
|
|
236
|
+
);
|
|
237
|
+
expect(after.models.selectedIndex).toBe(2);
|
|
238
|
+
expect(after.models.data).toBe(before.models.data);
|
|
239
|
+
expect(after.styles).toBe(before.styles);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("selection state", () => {
|
|
244
|
+
test("select records the index verbatim", () => {
|
|
245
|
+
expect(
|
|
246
|
+
run([{ type: "MODELS_SELECT", index: 3 }]).models.selectedIndex,
|
|
247
|
+
).toBe(3);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("models actions touch no other screen's state", () => {
|
|
251
|
+
// One shared reducer, so a mistyped spread is a real hazard: `...state`
|
|
252
|
+
// rather than `...state.models` would wipe every other screen.
|
|
253
|
+
const before = initialState;
|
|
254
|
+
const after = run([
|
|
255
|
+
{ type: "MODELS_SELECT", index: 2 },
|
|
256
|
+
{ type: "MODELS_APPLY_START" },
|
|
257
|
+
{ type: "MODELS_SEARCH_APPEND", char: "x" },
|
|
258
|
+
]);
|
|
259
|
+
expect(after.styles).toBe(before.styles);
|
|
260
|
+
expect(after.plugins).toBe(before.plugins);
|
|
261
|
+
expect(after.skills).toBe(before.skills);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── Keybindings ──────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const SCREEN = join(import.meta.dir, "..", "ui", "screens", "ModelsScreen.tsx");
|
|
268
|
+
|
|
269
|
+
describe("ModelsScreen keybindings", () => {
|
|
270
|
+
test("a and Enter both apply, and c clears", async () => {
|
|
271
|
+
const src = await fs.readFile(SCREEN, "utf8");
|
|
272
|
+
expect(src).toContain('event.name === "a"');
|
|
273
|
+
expect(src).toContain('event.name === "c"');
|
|
274
|
+
expect(src).toContain('event.name === "r"');
|
|
275
|
+
expect(src).toContain('event.name === "/"');
|
|
276
|
+
// Enter is the same action as `a`. There is nothing to "open" here, so
|
|
277
|
+
// binding the most obvious key to nothing would be the wrong lesson.
|
|
278
|
+
const enter = src.indexOf('event.name === "return"');
|
|
279
|
+
expect(enter).toBeGreaterThan(-1);
|
|
280
|
+
expect(src.slice(enter, enter + 400)).toContain("handleApply()");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("digits are treated as navigation, so a message survives a tab round trip", async () => {
|
|
284
|
+
// Every digit now navigates, 0 included. Clearing the status line on the
|
|
285
|
+
// way OUT of the tab loses the message the state was changed to preserve.
|
|
286
|
+
const src = await fs.readFile(SCREEN, "utf8");
|
|
287
|
+
expect(src).toContain("/^[0-9]$/.test(event.name");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("no screen still treats 0 as a non-navigation key", async () => {
|
|
291
|
+
// The Styles screen shipped `/^[1-9]$/` before Models existed on key 0.
|
|
292
|
+
const styles = await fs.readFile(
|
|
293
|
+
join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
|
|
294
|
+
"utf8",
|
|
295
|
+
);
|
|
296
|
+
expect(styles).not.toContain("/^[1-9]$/");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("screen state is held in the reducer, never in useState", async () => {
|
|
300
|
+
// The rule the two "survives unmounting" tests above enforce at runtime,
|
|
301
|
+
// pinned at the source so a new piece of state cannot quietly opt out.
|
|
302
|
+
//
|
|
303
|
+
// Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
|
|
304
|
+
// is about CODE, and this screen documents why each piece of its state lives in the
|
|
305
|
+
// reducer — naming the hook it is deliberately not using is the clearest way to say
|
|
306
|
+
// that, and a guard reading the prose would forbid the explanation along with the
|
|
307
|
+
// thing explained.
|
|
308
|
+
const src = stripComments(await fs.readFile(SCREEN, "utf8"));
|
|
309
|
+
expect(src).not.toContain("useState");
|
|
310
|
+
// Negative control: stripping must not have eaten the code being guarded. A guard
|
|
311
|
+
// over an empty string passes forever.
|
|
312
|
+
expect(src).toContain("useEffect");
|
|
313
|
+
expect(src).toContain("useCallback");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { openInDefaultApp, openerFor } from "../utils/open-file.js";
|
|
3
|
+
|
|
4
|
+
describe("openerFor", () => {
|
|
5
|
+
test("uses the macOS opener on darwin", () => {
|
|
6
|
+
expect(openerFor("darwin")).toEqual({ command: "open", args: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("uses xdg-open on linux", () => {
|
|
10
|
+
// magus ships a linux-x64 binary, and the pre-existing `open "${url}"`
|
|
11
|
+
// call elsewhere in the UI silently does nothing there.
|
|
12
|
+
expect(openerFor("linux")).toEqual({ command: "xdg-open", args: [] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("passes an empty window title to cmd start on windows", () => {
|
|
16
|
+
// `start` treats its first quoted argument as the window title, so
|
|
17
|
+
// without the empty string a quoted path is consumed as one.
|
|
18
|
+
expect(openerFor("win32")).toEqual({
|
|
19
|
+
command: "cmd",
|
|
20
|
+
args: ["/c", "start", ""],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("falls back to xdg-open on anything else", () => {
|
|
25
|
+
expect(openerFor("freebsd").command).toBe("xdg-open");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("openInDefaultApp", () => {
|
|
30
|
+
const missingOpener = {
|
|
31
|
+
command: "magus-test-opener-that-cannot-exist",
|
|
32
|
+
args: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
test("reports a missing opener rather than failing silently", async () => {
|
|
36
|
+
// An injected command that cannot exist, NOT a real platform's opener.
|
|
37
|
+
// The first version assumed `xdg-open` was absent — true on a Mac dev
|
|
38
|
+
// box, false on the Ubuntu CI runner, where it both failed the
|
|
39
|
+
// assertion and spawned a real opener on the build machine.
|
|
40
|
+
await expect(
|
|
41
|
+
openInDefaultApp("/tmp/whatever.md", "linux", missingOpener),
|
|
42
|
+
).rejects.toThrow(/not on PATH/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("settles promptly instead of hanging", async () => {
|
|
46
|
+
// The promise must settle when the child is spawned, not when the viewer
|
|
47
|
+
// is closed — waiting for exit would freeze the TUI for as long as the
|
|
48
|
+
// user's editor stayed open. Asserted on the error path so the suite
|
|
49
|
+
// never launches a real application on the developer's desktop.
|
|
50
|
+
const settled = await Promise.race([
|
|
51
|
+
openInDefaultApp("/tmp/whatever.md", "linux", missingOpener).then(
|
|
52
|
+
() => "resolved",
|
|
53
|
+
() => "rejected",
|
|
54
|
+
),
|
|
55
|
+
new Promise((resolve) => setTimeout(() => resolve("timeout"), 2000)),
|
|
56
|
+
]);
|
|
57
|
+
expect(settled).not.toBe("timeout");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change B: our own marketplaces (magus, magus-marketing) are always listed in
|
|
3
|
+
* the plugins TUI — even when unregistered — and sorted to the top.
|
|
4
|
+
*
|
|
5
|
+
* `magus-marketing` is deliberately NOT `featured`, so before this change it was
|
|
6
|
+
* invisible until manually registered. The `owned` flag both (a) qualifies it
|
|
7
|
+
* for the plugin-manager "always include" gate and (b) sorts it first.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
defaultMarketplaces,
|
|
12
|
+
getAllMarketplaces,
|
|
13
|
+
} from "../data/marketplaces.js";
|
|
14
|
+
import type { LocalMarketplace } from "../services/local-marketplace.js";
|
|
15
|
+
import type { Marketplace } from "../types/index.js";
|
|
16
|
+
import { buildPluginBrowserItems } from "../ui/adapters/pluginsAdapter.js";
|
|
17
|
+
|
|
18
|
+
describe("owned marketplaces", () => {
|
|
19
|
+
it("marks magus and magus-marketing as owned, and leaves others untouched", () => {
|
|
20
|
+
const magus = defaultMarketplaces.find((m) => m.name === "magus");
|
|
21
|
+
const marketing = defaultMarketplaces.find(
|
|
22
|
+
(m) => m.name === "magus-marketing",
|
|
23
|
+
);
|
|
24
|
+
expect(magus?.owned).toBe(true);
|
|
25
|
+
expect(marketing?.owned).toBe(true);
|
|
26
|
+
expect(
|
|
27
|
+
defaultMarketplaces.find((m) => m.name === "claude-plugins-official")
|
|
28
|
+
?.owned,
|
|
29
|
+
).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("qualifies magus-marketing for the always-show gate without being featured", () => {
|
|
33
|
+
const marketing = defaultMarketplaces.find(
|
|
34
|
+
(m) => m.name === "magus-marketing",
|
|
35
|
+
);
|
|
36
|
+
expect(marketing).toBeDefined();
|
|
37
|
+
// The plugin-manager gate: configured || official || featured || owned.
|
|
38
|
+
const alwaysShown = !!(
|
|
39
|
+
marketing?.official ||
|
|
40
|
+
marketing?.featured ||
|
|
41
|
+
marketing?.owned
|
|
42
|
+
);
|
|
43
|
+
expect(alwaysShown).toBe(true);
|
|
44
|
+
expect(marketing?.featured).toBeUndefined(); // and NOT via featured
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sorts owned first and deprecated last, regardless of input order", () => {
|
|
48
|
+
const mk = (name: string, extra: Partial<Marketplace>): Marketplace => ({
|
|
49
|
+
name,
|
|
50
|
+
displayName: name,
|
|
51
|
+
source: { source: "github", repo: `x/${name}` },
|
|
52
|
+
description: "",
|
|
53
|
+
...extra,
|
|
54
|
+
});
|
|
55
|
+
// Deliberately "wrong" order: official, deprecated, owned.
|
|
56
|
+
const marketplaces: Marketplace[] = [
|
|
57
|
+
mk("official-one", { official: true }),
|
|
58
|
+
mk("claude-code-plugins", { deprecated: true }),
|
|
59
|
+
mk("mine", { owned: true }),
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const items = buildPluginBrowserItems({
|
|
63
|
+
marketplaces,
|
|
64
|
+
plugins: [],
|
|
65
|
+
collapsedMarketplaces: new Set(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const order: string[] = [];
|
|
69
|
+
for (const i of items)
|
|
70
|
+
if (i.kind === "category") order.push(i.marketplace.name);
|
|
71
|
+
|
|
72
|
+
expect(order[0]).toBe("mine"); // owned first
|
|
73
|
+
expect(order[order.length - 1]).toBe("claude-code-plugins"); // deprecated last
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("preserves owned when getAllMarketplaces rebuilds a locally-cached marketplace", () => {
|
|
77
|
+
// The local-cache path reconstructs the Marketplace object; owned must
|
|
78
|
+
// survive so already-cloned magus still sorts first / always shows.
|
|
79
|
+
const local = new Map<string, LocalMarketplace>([
|
|
80
|
+
[
|
|
81
|
+
"magus",
|
|
82
|
+
{
|
|
83
|
+
name: "Magus",
|
|
84
|
+
description: "",
|
|
85
|
+
gitRepo: "MadAppGang/magus",
|
|
86
|
+
plugins: [],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
]);
|
|
90
|
+
const magus = getAllMarketplaces(local).find((m) => m.name === "magus");
|
|
91
|
+
expect(magus?.owned).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
checkoutDirName,
|
|
5
|
+
checkoutPath,
|
|
6
|
+
ensureCheckout,
|
|
7
|
+
} from "../services/plugin-checkout.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A checkout is a read-only shallow clone magus owns, used so that browsing
|
|
11
|
+
* what a plugin contains never requires installing it and never spends the
|
|
12
|
+
* GitHub API budget (60 requests an hour, unauthenticated, shared across every
|
|
13
|
+
* repo on screen).
|
|
14
|
+
*/
|
|
15
|
+
describe("checkoutDirName", () => {
|
|
16
|
+
it("flattens owner/repo into one filesystem-safe segment", () => {
|
|
17
|
+
expect(checkoutDirName("addyosmani/agent-skills")).toBe(
|
|
18
|
+
"addyosmani__agent-skills",
|
|
19
|
+
);
|
|
20
|
+
expect(checkoutDirName("MadAppGang/magus")).toBe("MadAppGang__magus");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("never yields a name containing a path separator", () => {
|
|
24
|
+
for (const repo of [
|
|
25
|
+
"a/b",
|
|
26
|
+
"../../etc/passwd",
|
|
27
|
+
"owner/repo.git",
|
|
28
|
+
"weird name/with spaces",
|
|
29
|
+
]) {
|
|
30
|
+
const name = checkoutDirName(repo);
|
|
31
|
+
expect(name).not.toContain("/");
|
|
32
|
+
expect(name).not.toContain("\\");
|
|
33
|
+
// A traversal attempt must not survive as one: `..` collapses into the
|
|
34
|
+
// same separator run as everything else, so the result cannot escape
|
|
35
|
+
// the checkout root.
|
|
36
|
+
expect(path.basename(name)).toBe(name);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps dots that are part of a real repo name", () => {
|
|
41
|
+
expect(checkoutDirName("owner/repo.js")).toBe("owner__repo.js");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("checkoutPath", () => {
|
|
46
|
+
it("puts every checkout under one root, outside Claude Code's own dirs", () => {
|
|
47
|
+
const a = checkoutPath("owner/one");
|
|
48
|
+
const b = checkoutPath("owner/two");
|
|
49
|
+
|
|
50
|
+
expect(path.dirname(a)).toBe(path.dirname(b));
|
|
51
|
+
expect(path.dirname(a)).toContain("magus-checkouts");
|
|
52
|
+
// Claude Code owns `plugins/`; a checkout must not look like installed
|
|
53
|
+
// state, or a reader could mistake it for something Claude Code loads.
|
|
54
|
+
expect(a).not.toContain(`${path.sep}plugins${path.sep}`);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("ensureCheckout", () => {
|
|
59
|
+
it("refuses to clone from a test run", async () => {
|
|
60
|
+
// Not a precaution. A test that only meant to exercise the tree-API path
|
|
61
|
+
// shallow-cloned a real GitHub repo, because stubbing `globalThis.fetch`
|
|
62
|
+
// does not stop `git`.
|
|
63
|
+
expect(process.env.NODE_ENV).toBe("test");
|
|
64
|
+
expect(await ensureCheckout("blader/humanizer")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `claude` subcommand does each plugin operation actually run?
|
|
3
|
+
*
|
|
4
|
+
* This file exists because the answer was wrong for a long time and nothing
|
|
5
|
+
* could see it. `updatePlugin` ran `claude plugin install`, on a comment that
|
|
6
|
+
* asserted install "handles both fresh installs and re-installs (upgrades)".
|
|
7
|
+
* It does not. Measured against Claude Code 2.1.252, with designer@magus
|
|
8
|
+
* installed at 0.6.0 and 0.6.1 published:
|
|
9
|
+
*
|
|
10
|
+
* $ claude plugin install designer@magus --scope project
|
|
11
|
+
* ✔ Plugin "designer@magus" is already installed (scope: project) # exit 0
|
|
12
|
+
*
|
|
13
|
+
* $ claude plugin update designer@magus --scope project
|
|
14
|
+
* ✔ Plugin "designer" updated from 0.6.0 to 0.6.1 for scope project # exit 0
|
|
15
|
+
*
|
|
16
|
+
* Both exit 0. The no-op is indistinguishable from success at every layer
|
|
17
|
+
* above, so every magus update path — the TUI's `U`, "update all", the
|
|
18
|
+
* scope menu, `magus update` — reported
|
|
19
|
+
* success and changed nothing, for ever.
|
|
20
|
+
*
|
|
21
|
+
* Mocking the CLI cannot catch that: a stub answers whatever the test asks it
|
|
22
|
+
* to. Only the argv is checkable here, so the argv is what these assert, with a
|
|
23
|
+
* real `claude` shim on PATH recording what it was called with.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
mkdtempSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
writeFileSync,
|
|
34
|
+
} from "node:fs";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import {
|
|
38
|
+
installPlugin,
|
|
39
|
+
repairPlugin,
|
|
40
|
+
updatePlugin,
|
|
41
|
+
} from "../services/claude-cli.js";
|
|
42
|
+
|
|
43
|
+
let root: string;
|
|
44
|
+
let logFile: string;
|
|
45
|
+
let savedPath: string | undefined;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
root = mkdtempSync(path.join(tmpdir(), "claude-argv-"));
|
|
49
|
+
const bin = path.join(root, "bin");
|
|
50
|
+
mkdirSync(bin);
|
|
51
|
+
logFile = path.join(root, "argv.log");
|
|
52
|
+
|
|
53
|
+
// A real executable, so this goes through which(1), execFile and the whole
|
|
54
|
+
// wrapper rather than a module double.
|
|
55
|
+
const shim = path.join(bin, "claude");
|
|
56
|
+
writeFileSync(shim, `#!/bin/sh\necho "$@" >> "${logFile}"\nexit 0\n`);
|
|
57
|
+
chmodSync(shim, 0o755);
|
|
58
|
+
|
|
59
|
+
savedPath = process.env.PATH;
|
|
60
|
+
// The shim dir goes FIRST so it shadows a real claude, but the system dirs
|
|
61
|
+
// stay: `getClaudePath` resolves through which(1), which lives in /usr/bin.
|
|
62
|
+
process.env.PATH = [bin, "/usr/bin", "/bin"].join(path.delimiter);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
process.env.PATH = savedPath ?? "";
|
|
67
|
+
rmSync(root, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Every argv the shim saw, one line per invocation. */
|
|
71
|
+
function calls(): string[] {
|
|
72
|
+
try {
|
|
73
|
+
return readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean);
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("which claude subcommand each operation runs", () => {
|
|
80
|
+
test("updatePlugin runs `plugin update` — NOT `plugin install`", async () => {
|
|
81
|
+
await updatePlugin("designer@magus", "project");
|
|
82
|
+
expect(calls()).toEqual(["plugin update designer@magus --scope project"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("installPlugin runs `plugin install`", async () => {
|
|
86
|
+
await installPlugin("designer@magus", "project");
|
|
87
|
+
expect(calls()).toEqual(["plugin install designer@magus --scope project"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("repairPlugin uninstalls then installs, in that order", async () => {
|
|
91
|
+
// Content drift under an unchanged version: `update` has no version bump
|
|
92
|
+
// to act on and `install` answers "already installed", so only the
|
|
93
|
+
// uninstall+install pair re-copies the files.
|
|
94
|
+
await repairPlugin("designer@magus", "project", root);
|
|
95
|
+
expect(calls()).toEqual([
|
|
96
|
+
"plugin uninstall designer@magus --scope project",
|
|
97
|
+
"plugin install designer@magus --scope project",
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("the scope reaches the CLI verbatim, at every scope", async () => {
|
|
102
|
+
await updatePlugin("dev@magus", "user");
|
|
103
|
+
await updatePlugin("dev@magus", "local");
|
|
104
|
+
expect(calls()).toEqual([
|
|
105
|
+
"plugin update dev@magus --scope user",
|
|
106
|
+
"plugin update dev@magus --scope local",
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
});
|