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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook path must never load the TUI.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code runs `magus hook …` on every matching tool call, so anything
|
|
5
|
+
* the entry point imports is paid for on every tool call — and `@opentui/*`
|
|
6
|
+
* plus the `src/ui/` tree is the expensive half of this program. Two guards,
|
|
7
|
+
* because they fail differently:
|
|
8
|
+
*
|
|
9
|
+
* - H-1 BUNDLES `src/cli/hook.ts` and inspects the output. A transitive import
|
|
10
|
+
* added three modules deep still shows up here.
|
|
11
|
+
* - H-2 reads `src/main.tsx` as text. Bundling cannot see this one: the TUI is
|
|
12
|
+
* reachable from main.tsx by design, just only through a dynamic import that
|
|
13
|
+
* the hook branch returns before. A *static* import would re-couple them
|
|
14
|
+
* while every runtime test still passed, so the source is what is pinned —
|
|
15
|
+
* the same technique dual-write-prevention.test.ts uses.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test } from "bun:test";
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
const HOOK_ENTRY = path.join(import.meta.dir, "..", "cli", "hook.ts");
|
|
23
|
+
const MAIN_TSX = path.join(import.meta.dir, "..", "main.tsx");
|
|
24
|
+
|
|
25
|
+
/** Drop `//` line comments and `/* *\/` blocks so the guards match code only. */
|
|
26
|
+
function stripComments(source: string): string {
|
|
27
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("hook entry point", () => {
|
|
31
|
+
test("H-1: bundling src/cli/hook.ts pulls in neither @opentui nor src/ui", async () => {
|
|
32
|
+
const build = await Bun.build({
|
|
33
|
+
entrypoints: [HOOK_ENTRY],
|
|
34
|
+
target: "bun",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(build.logs).toEqual([]);
|
|
38
|
+
expect(build.success).toBe(true);
|
|
39
|
+
expect(build.outputs.length).toBeGreaterThan(0);
|
|
40
|
+
|
|
41
|
+
const bundle = (
|
|
42
|
+
await Promise.all(build.outputs.map((output) => output.text()))
|
|
43
|
+
).join("\n");
|
|
44
|
+
|
|
45
|
+
expect(bundle).not.toContain("@opentui");
|
|
46
|
+
expect(bundle).not.toContain("/ui/");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("main.tsx import policy", () => {
|
|
51
|
+
const source = stripComments(readFileSync(MAIN_TSX, "utf-8"));
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every STATIC import/export specifier. `from "…"` only, which is exactly
|
|
55
|
+
* what a dynamic `import("…")` does not have — so the dynamic loads main.tsx
|
|
56
|
+
* relies on are invisible here, and that is the point.
|
|
57
|
+
*/
|
|
58
|
+
const staticSpecifiers = [
|
|
59
|
+
...source.matchAll(/\bfrom\s+["']([^"']+)["']/g),
|
|
60
|
+
].map((match) => match[1] as string);
|
|
61
|
+
|
|
62
|
+
test("H-2: no static import of @opentui/*", () => {
|
|
63
|
+
expect(staticSpecifiers.filter((s) => s.startsWith("@opentui"))).toEqual(
|
|
64
|
+
[],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("H-2b: no static import of ./ui/*", () => {
|
|
69
|
+
expect(staticSpecifiers.filter((s) => s.startsWith("./ui/"))).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("H-2c: the guard sees the specifiers it is meant to see", () => {
|
|
73
|
+
// Negative control: a regex that matched nothing would pass H-2 and H-2b
|
|
74
|
+
// forever. main.tsx does still carry static imports — just harmless ones.
|
|
75
|
+
expect(staticSpecifiers.length).toBeGreaterThan(0);
|
|
76
|
+
expect(staticSpecifiers).toContain("./services/dotenv.js");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("H-3: the hook branch is dispatched before the .env load", () => {
|
|
80
|
+
// Ordering, at source level, for the same reason theme-env.test.ts pins
|
|
81
|
+
// the snapshot: a hook must never open a project .env — it may be a
|
|
82
|
+
// symlink to a FIFO, and reading one blocks on every tool call.
|
|
83
|
+
const hookAt = source.indexOf('await import("./cli/hook.js")');
|
|
84
|
+
const dotenvAt = source.indexOf("loadProjectDotenv(");
|
|
85
|
+
|
|
86
|
+
expect(hookAt).toBeGreaterThan(-1);
|
|
87
|
+
expect(dotenvAt).toBeGreaterThan(-1);
|
|
88
|
+
expect(hookAt).toBeLessThan(dotenvAt);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hook as Claude Code actually runs it: a spawned process, a JSON payload on stdin, and
|
|
3
|
+
* whatever lands on stdout.
|
|
4
|
+
*
|
|
5
|
+
* `models-core.test.ts` covers the decision logic in-process. This file covers the things only
|
|
6
|
+
* a real spawn can show — that the binary starts, that it reads stdin, that it finds a config
|
|
7
|
+
* by walking up from the payload's cwd, and above all that EVERY failure mode is silent and
|
|
8
|
+
* exit 0. Claude Code spawns this on every Agent call in every session on the machine, so a
|
|
9
|
+
* hook that throws, hangs or exits non-zero would break delegation everywhere at once.
|
|
10
|
+
*
|
|
11
|
+
* The negative control is the point of the file: without a config, the same invocation must
|
|
12
|
+
* produce nothing at all. A test that only checks the happy path cannot tell a working hook
|
|
13
|
+
* from one that injects unconditionally.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
mkdirSync,
|
|
18
|
+
mkdtempSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
symlinkSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const MAIN = join(dirname(fileURLToPath(import.meta.url)), "..", "main.tsx");
|
|
28
|
+
|
|
29
|
+
let dir: string;
|
|
30
|
+
|
|
31
|
+
interface RunResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
exitCode: number;
|
|
35
|
+
ms: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Spawn the hook exactly as a settings.json `command` entry would. */
|
|
39
|
+
async function runHook(
|
|
40
|
+
payload: unknown,
|
|
41
|
+
opts: { cwd?: string; args?: string[] } = {},
|
|
42
|
+
): Promise<RunResult> {
|
|
43
|
+
const started = Date.now();
|
|
44
|
+
const proc = Bun.spawn(
|
|
45
|
+
["bun", "--no-env-file", MAIN, "hook", ...(opts.args ?? ["agent-model"])],
|
|
46
|
+
{
|
|
47
|
+
cwd: opts.cwd ?? dir,
|
|
48
|
+
stdin: new TextEncoder().encode(
|
|
49
|
+
typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
50
|
+
),
|
|
51
|
+
stdout: "pipe",
|
|
52
|
+
stderr: "pipe",
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
56
|
+
new Response(proc.stdout).text(),
|
|
57
|
+
new Response(proc.stderr).text(),
|
|
58
|
+
proc.exited,
|
|
59
|
+
]);
|
|
60
|
+
return { stdout, stderr, exitCode, ms: Date.now() - started };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CONFIG = {
|
|
64
|
+
version: 1,
|
|
65
|
+
preset: "test",
|
|
66
|
+
main: { model: "opus", effort: "medium" },
|
|
67
|
+
grades: {
|
|
68
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
69
|
+
normal: { model: "opus", effort: "medium" },
|
|
70
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
71
|
+
},
|
|
72
|
+
agents: {
|
|
73
|
+
"dev:architect": "smart",
|
|
74
|
+
Explore: "cheap",
|
|
75
|
+
"dev:researcher": { model: "inherit" },
|
|
76
|
+
},
|
|
77
|
+
fallback: "normal",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function writeConfig(at: string, config: unknown = CONFIG): void {
|
|
81
|
+
mkdirSync(join(at, ".claude"), { recursive: true });
|
|
82
|
+
writeFileSync(
|
|
83
|
+
join(at, ".claude", "models.json"),
|
|
84
|
+
JSON.stringify(config, null, 2),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const agentPayload = (input: Record<string, unknown> = {}, cwd = dir) => ({
|
|
89
|
+
hook_event_name: "PreToolUse",
|
|
90
|
+
tool_name: "Agent",
|
|
91
|
+
cwd,
|
|
92
|
+
tool_input: { subagent_type: "dev:architect", prompt: "go", ...input },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
dir = mkdtempSync(join(tmpdir(), "magus-hook-"));
|
|
97
|
+
});
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
rmSync(dir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("magus hook agent-model", () => {
|
|
103
|
+
test("injects the routed model, and emits hookEventName", async () => {
|
|
104
|
+
writeConfig(dir);
|
|
105
|
+
const r = await runHook(agentPayload());
|
|
106
|
+
expect(r.exitCode).toBe(0);
|
|
107
|
+
expect(r.stderr).toBe("");
|
|
108
|
+
expect(JSON.parse(r.stdout)).toEqual({
|
|
109
|
+
hookSpecificOutput: {
|
|
110
|
+
hookEventName: "PreToolUse",
|
|
111
|
+
updatedInput: {
|
|
112
|
+
subagent_type: "dev:architect",
|
|
113
|
+
prompt: "go",
|
|
114
|
+
model: "fable",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("NEGATIVE CONTROL: no config means no output at all", async () => {
|
|
121
|
+
// Without this the happy path above cannot distinguish routing from injecting always.
|
|
122
|
+
const r = await runHook(agentPayload());
|
|
123
|
+
expect(r.exitCode).toBe(0);
|
|
124
|
+
expect(r.stdout).toBe("");
|
|
125
|
+
expect(r.stderr).toBe("");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("walks up to a config in a parent directory", async () => {
|
|
129
|
+
writeConfig(dir);
|
|
130
|
+
const nested = join(dir, "packages", "api", "src");
|
|
131
|
+
mkdirSync(nested, { recursive: true });
|
|
132
|
+
const r = await runHook(agentPayload({}, nested));
|
|
133
|
+
expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
|
|
134
|
+
"fable",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("uses the payload's cwd, not the process's", async () => {
|
|
139
|
+
// A session's directory is not where this process was started, and the payload is the
|
|
140
|
+
// only thing that knows the difference.
|
|
141
|
+
const elsewhere = mkdtempSync(join(tmpdir(), "magus-hook-other-"));
|
|
142
|
+
try {
|
|
143
|
+
writeConfig(elsewhere);
|
|
144
|
+
const r = await runHook(agentPayload({}, elsewhere), { cwd: dir });
|
|
145
|
+
expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
|
|
146
|
+
"fable",
|
|
147
|
+
);
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(elsewhere, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("respects a model the caller already set", async () => {
|
|
154
|
+
writeConfig(dir);
|
|
155
|
+
const r = await runHook(agentPayload({ model: "haiku" }));
|
|
156
|
+
expect(r.stdout).toBe("");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("`inherit` leaves the call alone", async () => {
|
|
160
|
+
writeConfig(dir);
|
|
161
|
+
const r = await runHook(agentPayload({ subagent_type: "dev:researcher" }));
|
|
162
|
+
expect(r.stdout).toBe("");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("an unknown agent takes the fallback grade", async () => {
|
|
166
|
+
writeConfig(dir);
|
|
167
|
+
const r = await runHook(agentPayload({ subagent_type: "brand-new:agent" }));
|
|
168
|
+
expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
|
|
169
|
+
"opus",
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("ignores a tool that is not Agent", async () => {
|
|
174
|
+
writeConfig(dir);
|
|
175
|
+
const r = await runHook({
|
|
176
|
+
tool_name: "Bash",
|
|
177
|
+
cwd: dir,
|
|
178
|
+
tool_input: { command: "ls" },
|
|
179
|
+
});
|
|
180
|
+
expect(r.stdout).toBe("");
|
|
181
|
+
expect(r.exitCode).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("fails open", () => {
|
|
185
|
+
test("garbage on stdin", async () => {
|
|
186
|
+
writeConfig(dir);
|
|
187
|
+
const r = await runHook("not json at all {{{");
|
|
188
|
+
expect(r.exitCode).toBe(0);
|
|
189
|
+
expect(r.stdout).toBe("");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("empty stdin", async () => {
|
|
193
|
+
writeConfig(dir);
|
|
194
|
+
const r = await runHook("");
|
|
195
|
+
expect(r.exitCode).toBe(0);
|
|
196
|
+
expect(r.stdout).toBe("");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an unparseable config", async () => {
|
|
200
|
+
mkdirSync(join(dir, ".claude"), { recursive: true });
|
|
201
|
+
writeFileSync(join(dir, ".claude", "models.json"), "{ broken");
|
|
202
|
+
const r = await runHook(agentPayload());
|
|
203
|
+
expect(r.exitCode).toBe(0);
|
|
204
|
+
expect(r.stdout).toBe("");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("an INVALID config routes nothing rather than injecting a bad model", async () => {
|
|
208
|
+
// Measured (AMR-1 bogus-model): a non-alias refuses the spawn outright, so injecting
|
|
209
|
+
// one would break every delegation for everyone who pulled the config.
|
|
210
|
+
writeConfig(dir, {
|
|
211
|
+
...CONFIG,
|
|
212
|
+
grades: { ...CONFIG.grades, smart: { model: "kimi-k2" } },
|
|
213
|
+
});
|
|
214
|
+
const r = await runHook(agentPayload());
|
|
215
|
+
expect(r.exitCode).toBe(0);
|
|
216
|
+
expect(r.stdout).toBe("");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a config from a future version", async () => {
|
|
220
|
+
writeConfig(dir, { ...CONFIG, version: 99 });
|
|
221
|
+
const r = await runHook(agentPayload());
|
|
222
|
+
expect(r.exitCode).toBe(0);
|
|
223
|
+
expect(r.stdout).toBe("");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an unrecognised hook name", async () => {
|
|
227
|
+
writeConfig(dir);
|
|
228
|
+
const r = await runHook(agentPayload(), { args: ["something-else"] });
|
|
229
|
+
expect(r.exitCode).toBe(0);
|
|
230
|
+
expect(r.stdout).toBe("");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a .env symlinked to a FIFO does not hang the hook", async () => {
|
|
235
|
+
// In this repo `.env` is often a symlink to a 1Password FIFO. Bun autoloads the cwd's
|
|
236
|
+
// .env before user code unless told not to, and reading a FIFO with no writer blocks
|
|
237
|
+
// forever — which would look exactly like Claude Code freezing on every Agent call.
|
|
238
|
+
writeConfig(dir);
|
|
239
|
+
const fifo = join(dir, "fifo-target");
|
|
240
|
+
Bun.spawnSync(["mkfifo", fifo]);
|
|
241
|
+
symlinkSync(fifo, join(dir, ".env"));
|
|
242
|
+
const r = await runHook(agentPayload());
|
|
243
|
+
expect(r.exitCode).toBe(0);
|
|
244
|
+
expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
|
|
245
|
+
"fable",
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("stays well inside a hook timeout", async () => {
|
|
250
|
+
writeConfig(dir);
|
|
251
|
+
const r = await runHook(agentPayload());
|
|
252
|
+
// Registered with timeout: 5s. From source (not the compiled binary) this measures
|
|
253
|
+
// ~0.02-0.3s; a regression that pulls the TUI back onto this path would blow past it.
|
|
254
|
+
expect(r.ms).toBeLessThan(3000);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-registration — putting `magus hook agent-model` into user settings.
|
|
3
|
+
*
|
|
4
|
+
* The pure functions are tested against plain objects; the writer is tested
|
|
5
|
+
* against a temp `CLAUDE_CONFIG_DIR`, never the operator's real one.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import {
|
|
13
|
+
AGENT_MODEL_HOOK_COMMAND,
|
|
14
|
+
ensureHookRegistered,
|
|
15
|
+
isAgentModelHookRegistered,
|
|
16
|
+
isHookRegistered,
|
|
17
|
+
registerAgentModelHook,
|
|
18
|
+
removeHookRegistration,
|
|
19
|
+
} from "../services/hook-registration.js";
|
|
20
|
+
|
|
21
|
+
const ENTRY = {
|
|
22
|
+
type: "command",
|
|
23
|
+
command: AGENT_MODEL_HOOK_COMMAND,
|
|
24
|
+
timeout: 5,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("ensureHookRegistered", () => {
|
|
28
|
+
test("writes the measured shape: a PreToolUse block matching Agent", () => {
|
|
29
|
+
const { settings, changed } = ensureHookRegistered({});
|
|
30
|
+
expect(changed).toBe(true);
|
|
31
|
+
expect(settings).toEqual({
|
|
32
|
+
hooks: { PreToolUse: [{ matcher: "Agent", hooks: [ENTRY] }] },
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Identity is the command string, so re-running magus never accumulates
|
|
37
|
+
// duplicate registrations.
|
|
38
|
+
test("is idempotent", () => {
|
|
39
|
+
const first = ensureHookRegistered({});
|
|
40
|
+
const second = ensureHookRegistered(first.settings);
|
|
41
|
+
expect(second.changed).toBe(false);
|
|
42
|
+
expect(second.settings).toBe(first.settings);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// A second block with the same matcher works, but makes the user's settings
|
|
46
|
+
// progressively less readable and looks like a bug when they open it.
|
|
47
|
+
test("appends into an existing Agent block instead of adding a second one", () => {
|
|
48
|
+
const existing = {
|
|
49
|
+
hooks: {
|
|
50
|
+
PreToolUse: [
|
|
51
|
+
{
|
|
52
|
+
matcher: "Agent",
|
|
53
|
+
hooks: [{ type: "command", command: "my-own-audit-hook" }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const { settings } = ensureHookRegistered(existing);
|
|
59
|
+
const blocks = (settings.hooks as any).PreToolUse;
|
|
60
|
+
expect(blocks).toHaveLength(1);
|
|
61
|
+
expect(blocks[0].hooks).toEqual([
|
|
62
|
+
{ type: "command", command: "my-own-audit-hook" },
|
|
63
|
+
ENTRY,
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("leaves other tools' hooks and other settings untouched", () => {
|
|
68
|
+
const existing = {
|
|
69
|
+
model: "opus",
|
|
70
|
+
hooks: {
|
|
71
|
+
PreToolUse: [
|
|
72
|
+
{ matcher: "Bash", hooks: [{ type: "command", command: "guard" }] },
|
|
73
|
+
],
|
|
74
|
+
Stop: [{ hooks: [{ type: "command", command: "chime" }] }],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const { settings } = ensureHookRegistered(existing);
|
|
78
|
+
expect(settings.model).toBe("opus");
|
|
79
|
+
expect((settings.hooks as any).Stop).toEqual(existing.hooks.Stop);
|
|
80
|
+
expect((settings.hooks as any).PreToolUse[0].matcher).toBe("Bash");
|
|
81
|
+
expect((settings.hooks as any).PreToolUse[1]).toEqual({
|
|
82
|
+
matcher: "Agent",
|
|
83
|
+
hooks: [ENTRY],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("does not mutate the object it was given", () => {
|
|
88
|
+
const original = { hooks: { PreToolUse: [] } };
|
|
89
|
+
ensureHookRegistered(original);
|
|
90
|
+
expect(original).toEqual({ hooks: { PreToolUse: [] } });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Never an absolute path: `magus upgrade` replaces the binary, and on npm
|
|
94
|
+
// the path carries a version directory. A recorded absolute path is dead
|
|
95
|
+
// after the next upgrade, and a hook that cannot start routes nothing with
|
|
96
|
+
// no error anyone sees.
|
|
97
|
+
test("registers the bare command, not a path", () => {
|
|
98
|
+
const { settings } = ensureHookRegistered({});
|
|
99
|
+
const command = (settings.hooks as any).PreToolUse[0].hooks[0].command;
|
|
100
|
+
expect(command).toBe("magus hook agent-model");
|
|
101
|
+
expect(command.includes("/")).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("isHookRegistered", () => {
|
|
106
|
+
test("false for every shape that is not a hooks array", () => {
|
|
107
|
+
expect(isHookRegistered({})).toBe(false);
|
|
108
|
+
expect(isHookRegistered({ hooks: "nope" })).toBe(false);
|
|
109
|
+
expect(isHookRegistered({ hooks: { PreToolUse: "nope" } })).toBe(false);
|
|
110
|
+
expect(isHookRegistered({ hooks: { PreToolUse: [{}] } })).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("finds the command wherever it sits", () => {
|
|
114
|
+
expect(
|
|
115
|
+
isHookRegistered({
|
|
116
|
+
hooks: {
|
|
117
|
+
PreToolUse: [
|
|
118
|
+
{ matcher: "Bash", hooks: [{ command: "x" }] },
|
|
119
|
+
{ matcher: "Agent", hooks: [{ command: "y" }, ENTRY] },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("removeHookRegistration", () => {
|
|
128
|
+
test("round-trips: register then remove leaves no trace", () => {
|
|
129
|
+
const { settings } = ensureHookRegistered({ model: "opus" });
|
|
130
|
+
const removed = removeHookRegistration(settings);
|
|
131
|
+
expect(removed.changed).toBe(true);
|
|
132
|
+
expect(removed.settings).toEqual({ model: "opus" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("keeps other commands in a shared Agent block", () => {
|
|
136
|
+
const { settings } = ensureHookRegistered({
|
|
137
|
+
hooks: {
|
|
138
|
+
PreToolUse: [{ matcher: "Agent", hooks: [{ command: "mine" }] }],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const removed = removeHookRegistration(settings);
|
|
142
|
+
expect((removed.settings.hooks as any).PreToolUse).toEqual([
|
|
143
|
+
{ matcher: "Agent", hooks: [{ command: "mine" }] },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("no-op when it was never registered", () => {
|
|
148
|
+
const before = { hooks: { Stop: [] } };
|
|
149
|
+
const removed = removeHookRegistration(before);
|
|
150
|
+
expect(removed.changed).toBe(false);
|
|
151
|
+
expect(removed.settings).toBe(before);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("registerAgentModelHook", () => {
|
|
156
|
+
let configDir: string;
|
|
157
|
+
let previous: string | undefined;
|
|
158
|
+
|
|
159
|
+
beforeEach(async () => {
|
|
160
|
+
configDir = await mkdtemp(join(tmpdir(), "hookreg-"));
|
|
161
|
+
previous = process.env.CLAUDE_CONFIG_DIR;
|
|
162
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
163
|
+
});
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
// biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
|
|
166
|
+
if (previous === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
167
|
+
else process.env.CLAUDE_CONFIG_DIR = previous;
|
|
168
|
+
await rm(configDir, { recursive: true, force: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const settingsPath = () => join(configDir, "settings.json");
|
|
172
|
+
|
|
173
|
+
test("honours CLAUDE_CONFIG_DIR and reports registered, then already", async () => {
|
|
174
|
+
expect(await registerAgentModelHook()).toBe("registered");
|
|
175
|
+
expect(await fs.pathExists(settingsPath())).toBe(true);
|
|
176
|
+
expect(await isAgentModelHookRegistered()).toBe(true);
|
|
177
|
+
expect(await registerAgentModelHook()).toBe("already");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an explicit configDir overrides the environment", async () => {
|
|
181
|
+
const other = await mkdtemp(join(tmpdir(), "hookreg-other-"));
|
|
182
|
+
try {
|
|
183
|
+
await registerAgentModelHook({ configDir: other });
|
|
184
|
+
expect(await fs.pathExists(join(other, "settings.json"))).toBe(true);
|
|
185
|
+
expect(await fs.pathExists(settingsPath())).toBe(false);
|
|
186
|
+
} finally {
|
|
187
|
+
await rm(other, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("preserves every other key in the user's settings", async () => {
|
|
192
|
+
await fs.outputJson(settingsPath(), {
|
|
193
|
+
model: "sonnet",
|
|
194
|
+
enabledPlugins: { "dev@magus": true },
|
|
195
|
+
hooks: { SessionStart: [{ hooks: [{ command: "hello" }] }] },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await registerAgentModelHook();
|
|
199
|
+
|
|
200
|
+
const after = await fs.readJson(settingsPath());
|
|
201
|
+
expect(after.model).toBe("sonnet");
|
|
202
|
+
expect(after.enabledPlugins).toEqual({ "dev@magus": true });
|
|
203
|
+
expect(after.hooks.SessionStart).toEqual([
|
|
204
|
+
{ hooks: [{ command: "hello" }] },
|
|
205
|
+
]);
|
|
206
|
+
expect(after.hooks.PreToolUse[0].matcher).toBe("Agent");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// The reason this does NOT use readGlobalSettings/writeGlobalSettings: those
|
|
210
|
+
// swallow a parse error and return {}, so a settings file a human is
|
|
211
|
+
// mid-edit in would come back holding one hook and nothing else.
|
|
212
|
+
test("throws on unparseable user settings and leaves the file alone", async () => {
|
|
213
|
+
await fs.outputFile(settingsPath(), '{ "model": "opus", ');
|
|
214
|
+
|
|
215
|
+
await expect(registerAgentModelHook()).rejects.toThrow(/not valid JSON/);
|
|
216
|
+
|
|
217
|
+
expect(await fs.readFile(settingsPath(), "utf8")).toBe(
|
|
218
|
+
'{ "model": "opus", ',
|
|
219
|
+
);
|
|
220
|
+
// And the read-side reports "not registered" rather than throwing, so
|
|
221
|
+
// doctor/status can still say something useful about a broken file.
|
|
222
|
+
expect(await isAgentModelHookRegistered()).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { runInstallCommand } from "../cli/install.js";
|
|
7
|
+
|
|
8
|
+
// Integration coverage for runInstallCommand's VALIDATION wiring — the paths
|
|
9
|
+
// that return before the closure is resolved. We deliberately don't exercise
|
|
10
|
+
// --check / the drift guard here: those resolve the closure, which reaches
|
|
11
|
+
// claude-settings/plugin-manager — modules other test files replace globally
|
|
12
|
+
// via bun's mock.module (not restorable across files in bun 1.3.10). The
|
|
13
|
+
// --check and drift LOGIC is covered by pure-function tests instead
|
|
14
|
+
// (install-plan.test.ts computeVersionDrift, profile-sync.test.ts computeSync).
|
|
15
|
+
//
|
|
16
|
+
// runInstallCommand takes an explicit projectPath so no cwd juggling is needed.
|
|
17
|
+
// Console is captured via explicit save/restore.
|
|
18
|
+
|
|
19
|
+
let logs: string[];
|
|
20
|
+
let errs: string[];
|
|
21
|
+
let origLog: any;
|
|
22
|
+
let origErr: any;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
logs = [];
|
|
25
|
+
errs = [];
|
|
26
|
+
origLog = console.log;
|
|
27
|
+
origErr = console.error;
|
|
28
|
+
console.log = (...a: unknown[]) => {
|
|
29
|
+
logs.push(a.join(" "));
|
|
30
|
+
};
|
|
31
|
+
console.error = (...a: unknown[]) => {
|
|
32
|
+
errs.push(a.join(" "));
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
console.log = origLog;
|
|
37
|
+
console.error = origErr;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function project(manifest: object): Promise<string> {
|
|
41
|
+
const dir = await mkdtemp(join(tmpdir(), "installcmd-"));
|
|
42
|
+
await fs.ensureDir(join(dir, ".claude"));
|
|
43
|
+
await fs.writeJson(join(dir, ".claude", "profiles.json"), manifest);
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("runInstallCommand — early-return paths", () => {
|
|
48
|
+
let dir: string | undefined;
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
51
|
+
dir = undefined;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Exit code is the contract these assert. (Console-content assertions are
|
|
55
|
+
// unreliable under bun's cross-file test interleaving, so we don't make
|
|
56
|
+
// them here — the messages themselves are covered by the validator's own
|
|
57
|
+
// unit tests in manifest.test.ts.)
|
|
58
|
+
|
|
59
|
+
test("invalid manifest (plugin id without @) → exit 1", async () => {
|
|
60
|
+
dir = await project({
|
|
61
|
+
version: 2,
|
|
62
|
+
profiles: { p: { name: "P", plugins: { dev: "1.0.0" } } },
|
|
63
|
+
});
|
|
64
|
+
expect(await runInstallCommand(["--check"], dir)).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("unknown profile → exit 1", async () => {
|
|
68
|
+
dir = await project({
|
|
69
|
+
version: 2,
|
|
70
|
+
profiles: {
|
|
71
|
+
frontend: { name: "Frontend", plugins: { "dev@magus": "latest" } },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
expect(await runInstallCommand(["ghost", "--check"], dir)).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("empty manifest (no profiles) → exit 1", async () => {
|
|
78
|
+
dir = await project({ version: 2, profiles: {} });
|
|
79
|
+
expect(await runInstallCommand(["--check"], dir)).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
});
|