magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Styles manager — discovery and composition of communication style presets.
|
|
3
|
+
*
|
|
4
|
+
* ## What a "style" is
|
|
5
|
+
*
|
|
6
|
+
* Claude Code activates exactly ONE output style at a time. The preset model
|
|
7
|
+
* is compositional instead — one verbosity preset plus any number of
|
|
8
|
+
* modifiers — so the combination has to be flattened into a single generated
|
|
9
|
+
* style file before the harness ever sees it. That flattening is what
|
|
10
|
+
* `composeStyleFile` does here.
|
|
11
|
+
*
|
|
12
|
+
* ## Where the presets come from
|
|
13
|
+
*
|
|
14
|
+
* `data/styles/*.md`, embedded in the binary at build time. They used to ship
|
|
15
|
+
* in the `style@magus` plugin and be discovered on disk, which meant two
|
|
16
|
+
* implementations of one composer and an empty Styles tab for anyone without
|
|
17
|
+
* the plugin. The plugin was retired at Marketplace 10.0.0; this is now the
|
|
18
|
+
* only composer, and the presets travel with the binary that parses them.
|
|
19
|
+
*
|
|
20
|
+
* ## Profile awareness
|
|
21
|
+
*
|
|
22
|
+
* `.claude/settings.json` is a symlink into `.claude/_profiles/<name>/` when a
|
|
23
|
+
* profile is active, so writing `outputStyle` through it lands in that
|
|
24
|
+
* profile. But `materializeProfile` REWRITES that file from the manifest on
|
|
25
|
+
* every `magus install`, so the live write alone silently regresses. The
|
|
26
|
+
* selection is therefore also recorded in `.claude/profiles.json` under
|
|
27
|
+
* `profiles.<name>.settings.outputStyle`, which is the durable source.
|
|
28
|
+
*
|
|
29
|
+
* The generated style file is named per-profile (`composed-<name>`) so two
|
|
30
|
+
* profiles can hold different styles without clobbering each other's file
|
|
31
|
+
* while both point at the same name.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { createHash } from "node:crypto";
|
|
35
|
+
import os from "node:os";
|
|
36
|
+
import path from "node:path";
|
|
37
|
+
import fs from "fs-extra";
|
|
38
|
+
import { findCommunityStyle } from "../data/community-styles.js";
|
|
39
|
+
import { EMBEDDED_PRESETS } from "../data/styles/index.js";
|
|
40
|
+
import {
|
|
41
|
+
getManifestPath,
|
|
42
|
+
mergeManifestSettings,
|
|
43
|
+
readManifest,
|
|
44
|
+
writeManifest,
|
|
45
|
+
} from "./manifest.js";
|
|
46
|
+
import { updateSettingsFile } from "./settings-file.js";
|
|
47
|
+
import { activeProfile } from "./symlink-manager.js";
|
|
48
|
+
|
|
49
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The id namespace fetched styles live in. `community:<sourceId>--<slug>`.
|
|
53
|
+
*
|
|
54
|
+
* Exported because `.claude/style.json` carries these ids verbatim and both the
|
|
55
|
+
* adapter and the screen need to split one back into a registry coordinate.
|
|
56
|
+
*/
|
|
57
|
+
export const COMMUNITY_ID_PREFIX = "community:";
|
|
58
|
+
|
|
59
|
+
export type StyleAxis = "verbosity" | "modifier";
|
|
60
|
+
|
|
61
|
+
/** A preset embedded in this binary, from `data/styles/`. */
|
|
62
|
+
export interface StylePreset {
|
|
63
|
+
kind: "preset";
|
|
64
|
+
/** Selection key. Equals `name` — presets share one namespace. */
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
/**
|
|
68
|
+
* What lists show. From the preset's `title:` frontmatter, else the slug.
|
|
69
|
+
* Display only — the CLI, declarations, and hashes all use `name`, the
|
|
70
|
+
* same split the `builtin-` captures already have.
|
|
71
|
+
*/
|
|
72
|
+
displayName: string;
|
|
73
|
+
axis: StyleAxis;
|
|
74
|
+
summary: string;
|
|
75
|
+
/** Preset names that cancel this one out when both are selected. */
|
|
76
|
+
conflicts: string[];
|
|
77
|
+
/** Template presets ship an empty table and cannot be applied directly. */
|
|
78
|
+
template: boolean;
|
|
79
|
+
body: string;
|
|
80
|
+
/**
|
|
81
|
+
* What the detail panel shows under "Source". A label rather than a real
|
|
82
|
+
* path: an embedded preset has no file on disk, and the open-in-editor
|
|
83
|
+
* action refuses presets before it ever reads this.
|
|
84
|
+
*/
|
|
85
|
+
path: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Where an importable style came from, which is what decides how it is grouped
|
|
90
|
+
* and whether it travels with the repository.
|
|
91
|
+
*
|
|
92
|
+
* - `anthropic` — a Claude Code built-in captured by
|
|
93
|
+
* `scripts/capture-builtin.ts`. Its text ships inside the Claude Code
|
|
94
|
+
* binary, so the file is a snapshot that goes stale on the next upgrade.
|
|
95
|
+
* - `team` — project-scoped, so it lives in the repo and commits with it. This
|
|
96
|
+
* is the one everyone on the project gets.
|
|
97
|
+
* - `personal` — user-scoped, so it exists only on this machine.
|
|
98
|
+
* - `community` — someone else's style, fetched from their GitHub repo on an
|
|
99
|
+
* explicit user action and cached outside `~/.claude/output-styles/`. We
|
|
100
|
+
* store no copy of the text until the user asks for one, and the file carries
|
|
101
|
+
* the coordinates it came from.
|
|
102
|
+
*/
|
|
103
|
+
export type StyleOrigin = "anthropic" | "team" | "personal" | "community";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Where a community style's words came from, as its own frontmatter records it.
|
|
107
|
+
*
|
|
108
|
+
* Read from text already on disk, so populating it costs no network call and
|
|
109
|
+
* `loadStyles` stays offline on every render. `source` is THE marker — its
|
|
110
|
+
* presence is what classifies the file as `community` in the first place.
|
|
111
|
+
*
|
|
112
|
+
* The two sentinels the fetcher writes are normalised back to null here, so the
|
|
113
|
+
* detail panel never has to know the words "unknown" and "unlicensed": a pin we
|
|
114
|
+
* could not resolve and a repo that grants no licence are both ABSENCES, and
|
|
115
|
+
* rendering them as values would let "unlicensed" read as an SPDX id.
|
|
116
|
+
*/
|
|
117
|
+
export interface CommunityProvenance {
|
|
118
|
+
/** `owner/name` of the upstream repository. */
|
|
119
|
+
source: string;
|
|
120
|
+
/** Path within that repository, including its styles directory. */
|
|
121
|
+
path: string | null;
|
|
122
|
+
/** The git ref we asked for — usually `HEAD`. */
|
|
123
|
+
ref: string | null;
|
|
124
|
+
/** Short commit sha this copy is pinned at, or null if none was resolved. */
|
|
125
|
+
commit: string | null;
|
|
126
|
+
/** `sha256:<64 hex>` over the RAW upstream bytes at fetch time. */
|
|
127
|
+
sha256: string | null;
|
|
128
|
+
/** Local date of the fetch, YYYY-MM-DD. */
|
|
129
|
+
fetched: string | null;
|
|
130
|
+
/** SPDX id, or null when upstream grants no redistribution licence. */
|
|
131
|
+
licence: string | null;
|
|
132
|
+
author: string | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** An output style already on this machine, importable into a composition. */
|
|
136
|
+
export interface ImportedStyle {
|
|
137
|
+
kind: "imported";
|
|
138
|
+
/** Selection key, `"user:name"` or `"project:name"` — scopes may collide. */
|
|
139
|
+
id: string;
|
|
140
|
+
/** The on-disk name. This is what `style-imports` records — never display-only. */
|
|
141
|
+
name: string;
|
|
142
|
+
/**
|
|
143
|
+
* What to show in the list. For a captured built-in this is Anthropic's own
|
|
144
|
+
* name ("Explanatory"), not the `builtin-explanatory` slug the file carries.
|
|
145
|
+
* Display only — composing still round-trips through `id`/`name`.
|
|
146
|
+
*/
|
|
147
|
+
displayName: string;
|
|
148
|
+
/**
|
|
149
|
+
* Which directory it was read from. `community` is the magus cache, not a
|
|
150
|
+
* Claude Code styles directory — see `communityCacheDirOrNull`.
|
|
151
|
+
*/
|
|
152
|
+
scope: "user" | "project" | "community";
|
|
153
|
+
origin: StyleOrigin;
|
|
154
|
+
/**
|
|
155
|
+
* True when magus owns this copy: it lives in the community cache, so it
|
|
156
|
+
* is re-fetchable and safe to delete. False for everything the user wrote or
|
|
157
|
+
* placed themselves, INCLUDING a community-marked file they copied into
|
|
158
|
+
* `~/.claude/output-styles/` — the marker says where the words came from, the
|
|
159
|
+
* directory says whose copy it is, and those are different questions.
|
|
160
|
+
*/
|
|
161
|
+
managed: boolean;
|
|
162
|
+
description: string;
|
|
163
|
+
/** File mtime as YYYY-MM-DD — when this copy was last written. */
|
|
164
|
+
updatedAt: string | null;
|
|
165
|
+
/** For a captured built-in: the Claude Code version it was taken from. */
|
|
166
|
+
capturedFrom: string | null;
|
|
167
|
+
/** For a community style: where its words came from. Null for everything else. */
|
|
168
|
+
community: CommunityProvenance | null;
|
|
169
|
+
body: string;
|
|
170
|
+
path: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type StyleSource = StylePreset | ImportedStyle;
|
|
174
|
+
|
|
175
|
+
/** Everything the Styles screen needs for one project, in one read. */
|
|
176
|
+
export interface StylesSnapshot {
|
|
177
|
+
presets: StylePreset[];
|
|
178
|
+
imports: ImportedStyle[];
|
|
179
|
+
/** Selection recorded in the generated style file; null if never applied. */
|
|
180
|
+
applied: { presets: string[]; imports: string[]; hash: string | null } | null;
|
|
181
|
+
/** The project's committed choice, `.claude/style.json`. */
|
|
182
|
+
declaration: StyleDeclaration | null;
|
|
183
|
+
/** Whether this machine's active style matches that declaration. */
|
|
184
|
+
status: StyleSyncStatus;
|
|
185
|
+
/** Where the generated style file is (or would be) written. */
|
|
186
|
+
stylePath: string;
|
|
187
|
+
/** The settings.json that carries `outputStyle`. */
|
|
188
|
+
settingsPath: string;
|
|
189
|
+
/** Name of the generated style, e.g. "composed" or "composed-dev". */
|
|
190
|
+
styleName: string;
|
|
191
|
+
/** Active profile name, when .claude/settings.json is a profile symlink. */
|
|
192
|
+
profile: string | null;
|
|
193
|
+
/** The value currently in settings.json, whatever set it. */
|
|
194
|
+
currentOutputStyle: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The project's committed style choice: `.claude/style.json`.
|
|
199
|
+
*
|
|
200
|
+
* The generated `composed.md` is an ARTIFACT — it is what Claude Code reads.
|
|
201
|
+
* This is the DECLARATION: the short, reviewable statement of what the project
|
|
202
|
+
* wants, which is the thing worth committing and arguing about in a pull
|
|
203
|
+
* request. A teammate who clones the repo gets the declaration, and magus
|
|
204
|
+
* can tell them their local artifact does not match it yet.
|
|
205
|
+
*/
|
|
206
|
+
export interface StyleDeclaration {
|
|
207
|
+
version: 1;
|
|
208
|
+
presets: string[];
|
|
209
|
+
imports: string[];
|
|
210
|
+
/**
|
|
211
|
+
* Hash of the composition this declaration produced when it was written.
|
|
212
|
+
*
|
|
213
|
+
* Over the source BODIES, not just their names: the same preset names
|
|
214
|
+
* compose to different text after a plugin update, and "you have the right
|
|
215
|
+
* names" is not the same claim as "you have the right rules".
|
|
216
|
+
*/
|
|
217
|
+
hash: string;
|
|
218
|
+
updatedAt: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** How the local generated style relates to the committed declaration. */
|
|
222
|
+
export type StyleSyncState =
|
|
223
|
+
/** No `.claude/style.json` — the project has not declared a style. */
|
|
224
|
+
| "undeclared"
|
|
225
|
+
/** Declared, but this machine has not applied it (or applied something else). */
|
|
226
|
+
| "not-applied"
|
|
227
|
+
/** Applied, but the composition would differ now — usually a pulled change. */
|
|
228
|
+
| "stale"
|
|
229
|
+
/** The active style matches the declaration exactly. */
|
|
230
|
+
| "in-sync";
|
|
231
|
+
|
|
232
|
+
export interface StyleSyncStatus {
|
|
233
|
+
state: StyleSyncState;
|
|
234
|
+
/** One line explaining the state, safe to show in the status bar. */
|
|
235
|
+
detail: string;
|
|
236
|
+
/** Names the declaration asks for that no longer resolve, and cannot be got. */
|
|
237
|
+
missing: string[];
|
|
238
|
+
/**
|
|
239
|
+
* Missing `community:` ids the registry still knows how to fetch.
|
|
240
|
+
*
|
|
241
|
+
* DISJOINT from `missing`, because they are different situations wearing the
|
|
242
|
+
* same shape: a name in `missing` is a dead end the user can do nothing
|
|
243
|
+
* about, and one in `fetchable` is a single keypress from resolved. Reporting
|
|
244
|
+
* both as "missing" is what would turn this feature's best case — a team
|
|
245
|
+
* commits a style choice including third-party text and everyone gets it —
|
|
246
|
+
* into an unactionable error.
|
|
247
|
+
*/
|
|
248
|
+
fetchable: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface ApplyResult {
|
|
252
|
+
stylePath: string;
|
|
253
|
+
settingsPath: string;
|
|
254
|
+
styleName: string;
|
|
255
|
+
profile: string | null;
|
|
256
|
+
/** True when the selection was also recorded in .claude/profiles.json. */
|
|
257
|
+
recordedInManifest: boolean;
|
|
258
|
+
/** The committed declaration that was written — commit this. */
|
|
259
|
+
declarationPath: string;
|
|
260
|
+
hash: string;
|
|
261
|
+
presets: string[];
|
|
262
|
+
imports: string[];
|
|
263
|
+
bytes: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Frontmatter ──────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
type Frontmatter = Record<string, string>;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Split `---` frontmatter from a markdown body.
|
|
272
|
+
*
|
|
273
|
+
* Deliberately not a YAML parser: the keys we read are flat scalars, and
|
|
274
|
+
* pulling in a YAML dependency to read five strings would be the tail wagging
|
|
275
|
+
* the dog. Quote-unescaping is included because a `description:` containing a
|
|
276
|
+
* colon has to be quoted to stay valid YAML for every other reader.
|
|
277
|
+
*/
|
|
278
|
+
export function splitFrontmatter(text: string): {
|
|
279
|
+
frontmatter: Frontmatter;
|
|
280
|
+
body: string;
|
|
281
|
+
} {
|
|
282
|
+
const lines = text.replace(/^\uFEFF/, "").split("\n");
|
|
283
|
+
if (lines[0]?.trim() !== "---") {
|
|
284
|
+
return { frontmatter: {}, body: text.trim() };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let close = -1;
|
|
288
|
+
for (let i = 1; i < lines.length; i++) {
|
|
289
|
+
if (lines[i].trim() === "---") {
|
|
290
|
+
close = i;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (close === -1) return { frontmatter: {}, body: text.trim() };
|
|
295
|
+
|
|
296
|
+
const frontmatter: Frontmatter = {};
|
|
297
|
+
for (const line of lines.slice(1, close)) {
|
|
298
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
299
|
+
if (!match) continue;
|
|
300
|
+
let value = match[2].trim();
|
|
301
|
+
const quoted =
|
|
302
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
303
|
+
(value.startsWith("'") && value.endsWith("'"));
|
|
304
|
+
if (quoted && value.length >= 2) {
|
|
305
|
+
value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
306
|
+
}
|
|
307
|
+
frontmatter[match[1].toLowerCase()] = value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
frontmatter,
|
|
312
|
+
body: lines
|
|
313
|
+
.slice(close + 1)
|
|
314
|
+
.join("\n")
|
|
315
|
+
.trim(),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function asBool(value: string | undefined): boolean | null {
|
|
320
|
+
if (value === undefined) return null;
|
|
321
|
+
const normalized = value.trim().toLowerCase();
|
|
322
|
+
if (normalized === "true" || normalized === "yes") return true;
|
|
323
|
+
if (normalized === "false" || normalized === "no") return false;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function splitList(value: string | undefined): string[] {
|
|
328
|
+
if (!value) return [];
|
|
329
|
+
return value
|
|
330
|
+
.split(",")
|
|
331
|
+
.map((entry) => entry.trim())
|
|
332
|
+
.filter(Boolean);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function markdownFiles(dir: string): Promise<string[]> {
|
|
336
|
+
try {
|
|
337
|
+
if (!(await fs.pathExists(dir))) return [];
|
|
338
|
+
if (!(await fs.stat(dir)).isDirectory()) return [];
|
|
339
|
+
const entries = await fs.readdir(dir);
|
|
340
|
+
const files: string[] = [];
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
if (!entry.endsWith(".md")) continue;
|
|
343
|
+
const full = path.join(dir, entry);
|
|
344
|
+
try {
|
|
345
|
+
if ((await fs.stat(full)).isFile()) files.push(full);
|
|
346
|
+
} catch {
|
|
347
|
+
/* unreadable entry — skip */
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return files.sort();
|
|
351
|
+
} catch {
|
|
352
|
+
return [];
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─── Presets ────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Parse the embedded preset files into usable presets.
|
|
360
|
+
*
|
|
361
|
+
* Synchronous and infallible by construction: the text is inlined at build
|
|
362
|
+
* time, so there is no directory to miss, no plugin to be uninstalled, and no
|
|
363
|
+
* version of the files other than the one this binary was built with. The
|
|
364
|
+
* frontmatter contract is `name`, `title`, `axis`, `summary`, `conflicts`,
|
|
365
|
+
* `template` — see `data/styles/index.ts`.
|
|
366
|
+
*/
|
|
367
|
+
export function loadPresets(): StylePreset[] {
|
|
368
|
+
const presets: StylePreset[] = [];
|
|
369
|
+
for (const { file, text } of EMBEDDED_PRESETS) {
|
|
370
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
371
|
+
const name = frontmatter.name || file.replace(/\.md$/, "");
|
|
372
|
+
presets.push({
|
|
373
|
+
kind: "preset",
|
|
374
|
+
id: name,
|
|
375
|
+
name,
|
|
376
|
+
displayName: frontmatter.title || name,
|
|
377
|
+
axis: frontmatter.axis === "verbosity" ? "verbosity" : "modifier",
|
|
378
|
+
summary: frontmatter.summary || "",
|
|
379
|
+
conflicts: splitList(frontmatter.conflicts),
|
|
380
|
+
template: asBool(frontmatter.template) === true,
|
|
381
|
+
body,
|
|
382
|
+
path: `bundled with magus · styles/${file}`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return presets;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Where fetched community styles are cached, or null when a test has not
|
|
390
|
+
* isolated itself.
|
|
391
|
+
*
|
|
392
|
+
* Deliberately NOT `~/.claude/output-styles/`. A fetched style is a copy of
|
|
393
|
+
* someone else's text that magus can overwrite or delete without asking; the
|
|
394
|
+
* output-styles directories are the user's own, and putting a re-fetchable copy
|
|
395
|
+
* in them makes "delete this" and "delete my work" the same keypress.
|
|
396
|
+
*
|
|
397
|
+
* The null case mirrors `catalog-cache-store.ts`. Under `bun test`, a `home`
|
|
398
|
+
* that is still the operator's real one means the test never isolated itself —
|
|
399
|
+
* it gets an empty Community section rather than reading, and later possibly
|
|
400
|
+
* writing, the operator's live cache. A test that DOES pass a temporary home
|
|
401
|
+
* gets a working directory, which is what makes the classification tests
|
|
402
|
+
* possible at all.
|
|
403
|
+
*/
|
|
404
|
+
export function communityCacheDirOrNull(home: string): string | null {
|
|
405
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR;
|
|
406
|
+
if (configDir) return path.join(configDir, "magus", "community-styles");
|
|
407
|
+
if (process.env.NODE_ENV === "test" && home === os.homedir()) return null;
|
|
408
|
+
return path.join(home, ".claude", "magus", "community-styles");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function readImportable(
|
|
412
|
+
projectPath: string,
|
|
413
|
+
generatedNames: string[],
|
|
414
|
+
home: string,
|
|
415
|
+
): Promise<ImportedStyle[]> {
|
|
416
|
+
const communityDir = communityCacheDirOrNull(home);
|
|
417
|
+
const scopes: Array<{ scope: ImportedStyle["scope"]; dir: string }> = [
|
|
418
|
+
{ scope: "user", dir: path.join(home, ".claude", "output-styles") },
|
|
419
|
+
{
|
|
420
|
+
scope: "project",
|
|
421
|
+
dir: path.join(projectPath, ".claude", "output-styles"),
|
|
422
|
+
},
|
|
423
|
+
];
|
|
424
|
+
// A third entry in the SAME array, which is why the cache is flat: it reuses
|
|
425
|
+
// the non-recursive `markdownFiles` verbatim, and `.pending/` (downloaded but
|
|
426
|
+
// not accepted updates) is invisible to discovery by construction rather than
|
|
427
|
+
// by a filter someone could drop.
|
|
428
|
+
if (communityDir) scopes.push({ scope: "community", dir: communityDir });
|
|
429
|
+
|
|
430
|
+
const found: ImportedStyle[] = [];
|
|
431
|
+
for (const { scope, dir } of scopes) {
|
|
432
|
+
for (const file of await markdownFiles(dir)) {
|
|
433
|
+
const { frontmatter, body } = splitFrontmatter(
|
|
434
|
+
await fs.readFile(file, "utf8"),
|
|
435
|
+
);
|
|
436
|
+
const basename = path.basename(file, ".md");
|
|
437
|
+
const declared = frontmatter.name || basename;
|
|
438
|
+
|
|
439
|
+
// A comma in `name` corrupts the declaration round trip: `composeStyleFile`
|
|
440
|
+
// serialises `style-imports` comma-separated and `readApplied` splits it
|
|
441
|
+
// back on commas, so an id containing one returns as two bogus ids and
|
|
442
|
+
// `computeStyleStatus` reports `stale` forever with no way to converge.
|
|
443
|
+
// The basename cannot carry one, and keeping the declared value as
|
|
444
|
+
// `displayName` means the user still sees the name they wrote. Fixing the
|
|
445
|
+
// SERIALISATION instead would change the format every already-written
|
|
446
|
+
// composed.md carries, so every user's next load would report a spurious
|
|
447
|
+
// drift — a worse trade for a bug that is latent rather than live.
|
|
448
|
+
const hasComma = declared.includes(",");
|
|
449
|
+
const name = hasComma ? basename : declared;
|
|
450
|
+
|
|
451
|
+
// Never import a file we generate — it would compound on every apply.
|
|
452
|
+
if (generatedNames.includes(name)) continue;
|
|
453
|
+
|
|
454
|
+
// Classification is by MARKER, never by directory, and in this order.
|
|
455
|
+
// `captured-style` keeps precedence: capture-builtin.ts writes it with
|
|
456
|
+
// Anthropic's own name for the style, and a captured built-in is an
|
|
457
|
+
// Anthropic style wherever it sits. The `builtin-` name prefix is only a
|
|
458
|
+
// fallback, because a file the user happened to call `builtin-…` is
|
|
459
|
+
// theirs, not Anthropic's.
|
|
460
|
+
const capturedStyle = frontmatter["captured-style"];
|
|
461
|
+
const isAnthropic = Boolean(capturedStyle) || name.startsWith("builtin-");
|
|
462
|
+
// `community-source` names the repo the words came from. It travels with
|
|
463
|
+
// the file, so a copy the user moves into their own output-styles
|
|
464
|
+
// directory still reports its origin honestly — it is simply no longer
|
|
465
|
+
// `managed`, so nothing offers to update or delete it.
|
|
466
|
+
const isCommunity =
|
|
467
|
+
!isAnthropic && Boolean(frontmatter["community-source"]);
|
|
468
|
+
|
|
469
|
+
found.push({
|
|
470
|
+
kind: "imported",
|
|
471
|
+
id: `${scope}:${name}`,
|
|
472
|
+
name,
|
|
473
|
+
// Display only. The registry carries upstream's own name ("Spartan",
|
|
474
|
+
// "Zen Master") because the fetched file's `name` is the coordinate id,
|
|
475
|
+
// which is machinery. Same split as the `builtin-` handling above.
|
|
476
|
+
displayName: isAnthropic
|
|
477
|
+
? capturedStyle || stripBuiltinPrefix(name)
|
|
478
|
+
: hasComma
|
|
479
|
+
? declared
|
|
480
|
+
: isCommunity
|
|
481
|
+
? (findCommunityStyle(name)?.displayName ?? name)
|
|
482
|
+
: name,
|
|
483
|
+
scope,
|
|
484
|
+
origin: isAnthropic
|
|
485
|
+
? "anthropic"
|
|
486
|
+
: isCommunity
|
|
487
|
+
? "community"
|
|
488
|
+
: scope === "project"
|
|
489
|
+
? "team"
|
|
490
|
+
: "personal",
|
|
491
|
+
// Only what magus put in its own cache. A stray hand-written file
|
|
492
|
+
// in that directory is still not ours to overwrite.
|
|
493
|
+
managed: scope === "community" && isCommunity,
|
|
494
|
+
description: frontmatter.description || "",
|
|
495
|
+
updatedAt: await fileDate(file),
|
|
496
|
+
capturedFrom: frontmatter["captured-from"] || null,
|
|
497
|
+
community: isCommunity ? readProvenance(frontmatter) : null,
|
|
498
|
+
body,
|
|
499
|
+
path: file,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return found;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Lift the `community-*` keys out of a fetched file's frontmatter.
|
|
508
|
+
*
|
|
509
|
+
* The two sentinels the fetcher stamps — `unknown` for an unresolved pin and
|
|
510
|
+
* `unlicensed` for a repo that grants no licence — come back as null. They exist
|
|
511
|
+
* in the FILE because a blank value there would read as a truncated write; they
|
|
512
|
+
* must not survive into the model, where "unlicensed" would render in the
|
|
513
|
+
* licence slot as though it were an SPDX id.
|
|
514
|
+
*/
|
|
515
|
+
function readProvenance(
|
|
516
|
+
frontmatter: Record<string, string>,
|
|
517
|
+
): CommunityProvenance {
|
|
518
|
+
const value = (key: string, sentinel?: string): string | null => {
|
|
519
|
+
const raw = frontmatter[key]?.trim();
|
|
520
|
+
if (!raw || raw === sentinel) return null;
|
|
521
|
+
return raw;
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
source: frontmatter["community-source"] ?? "",
|
|
525
|
+
path: value("community-path"),
|
|
526
|
+
ref: value("community-ref"),
|
|
527
|
+
commit: value("community-commit", "unknown"),
|
|
528
|
+
sha256: value("community-sha256"),
|
|
529
|
+
fetched: value("community-fetched"),
|
|
530
|
+
licence: value("community-licence", "unlicensed"),
|
|
531
|
+
author: value("community-author"),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** `builtin-plain-text` -> `Plain text`. Only a fallback; see readImportable. */
|
|
536
|
+
export function stripBuiltinPrefix(name: string): string {
|
|
537
|
+
const bare = name.replace(/^builtin-/, "").replace(/-/g, " ");
|
|
538
|
+
return bare ? bare.charAt(0).toUpperCase() + bare.slice(1) : name;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Last-written date as YYYY-MM-DD, or null if the file cannot be stat'd. */
|
|
542
|
+
async function fileDate(file: string): Promise<string | null> {
|
|
543
|
+
try {
|
|
544
|
+
const { mtime } = await fs.stat(file);
|
|
545
|
+
// Local date, not toISOString(): the user reads this against their own
|
|
546
|
+
// calendar, and UTC would show yesterday for anything written after 5pm
|
|
547
|
+
// in a western timezone.
|
|
548
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
549
|
+
return `${mtime.getFullYear()}-${pad(mtime.getMonth() + 1)}-${pad(mtime.getDate())}`;
|
|
550
|
+
} catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ─── Hashing and the committed declaration ────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Fingerprint a resolved composition.
|
|
559
|
+
*
|
|
560
|
+
* Covers each source's id AND body, in order, because all three of those change
|
|
561
|
+
* what Claude Code is actually told:
|
|
562
|
+
*
|
|
563
|
+
* - a different set of presets,
|
|
564
|
+
* - the same set in a different order (specific-after-broad matters),
|
|
565
|
+
* - the same set whose text changed under a plugin update.
|
|
566
|
+
*
|
|
567
|
+
* Hashing only the names would answer "did someone pick differently" and miss
|
|
568
|
+
* the third case entirely, which is the one nobody notices by eye.
|
|
569
|
+
*
|
|
570
|
+
* The integrity block is hashed too, even though it is not a source. It is text
|
|
571
|
+
* the model is given, it can change under a magus upgrade, and the third
|
|
572
|
+
* case above is exactly "the text changed while the names did not" — leaving it
|
|
573
|
+
* out would let `computeStyleStatus` report `in-sync` for a file that still
|
|
574
|
+
* carries the old block. Revising the block therefore costs every project one
|
|
575
|
+
* "project style changed — press a to re-apply", which is the honest signal.
|
|
576
|
+
*/
|
|
577
|
+
export function styleHash(sources: StyleSource[]): string {
|
|
578
|
+
const hash = createHash("sha256");
|
|
579
|
+
for (const source of sources) {
|
|
580
|
+
hash.update(source.id);
|
|
581
|
+
hash.update("");
|
|
582
|
+
hash.update(source.body);
|
|
583
|
+
hash.update("");
|
|
584
|
+
}
|
|
585
|
+
hash.update(INTEGRITY_BLOCK);
|
|
586
|
+
return `sha256:${hash.digest("hex").slice(0, 32)}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function declarationPath(projectPath: string): string {
|
|
590
|
+
return path.join(projectPath, ".claude", "style.json");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Read the committed declaration, or null when the project has none. */
|
|
594
|
+
export async function readStyleDeclaration(
|
|
595
|
+
projectPath: string,
|
|
596
|
+
): Promise<StyleDeclaration | null> {
|
|
597
|
+
const file = declarationPath(projectPath);
|
|
598
|
+
try {
|
|
599
|
+
if (!(await fs.pathExists(file))) return null;
|
|
600
|
+
const parsed = (await fs.readJson(file)) as Partial<StyleDeclaration>;
|
|
601
|
+
if (!Array.isArray(parsed.presets) || !Array.isArray(parsed.imports)) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
version: 1,
|
|
606
|
+
presets: parsed.presets.filter((v): v is string => typeof v === "string"),
|
|
607
|
+
imports: parsed.imports.filter((v): v is string => typeof v === "string"),
|
|
608
|
+
hash: typeof parsed.hash === "string" ? parsed.hash : "",
|
|
609
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
|
|
610
|
+
};
|
|
611
|
+
} catch {
|
|
612
|
+
// A corrupt declaration is reported as "none" rather than crashing the
|
|
613
|
+
// screen; re-applying rewrites it.
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function writeStyleDeclaration(
|
|
619
|
+
projectPath: string,
|
|
620
|
+
declaration: StyleDeclaration,
|
|
621
|
+
): Promise<void> {
|
|
622
|
+
const file = declarationPath(projectPath);
|
|
623
|
+
await fs.ensureDir(path.dirname(file));
|
|
624
|
+
await fs.writeJson(file, declaration, { spaces: 2 });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Resolve the declaration against what is on this machine right now.
|
|
629
|
+
*
|
|
630
|
+
* Returns the sources in composition order plus anything that no longer
|
|
631
|
+
* resolves — a preset the plugin dropped, or an imported style the teammate
|
|
632
|
+
* who committed the declaration has but this machine does not.
|
|
633
|
+
*/
|
|
634
|
+
export function resolveDeclaration(
|
|
635
|
+
declaration: StyleDeclaration,
|
|
636
|
+
presets: StylePreset[],
|
|
637
|
+
imports: ImportedStyle[],
|
|
638
|
+
): { sources: StyleSource[]; missing: string[]; fetchable: string[] } {
|
|
639
|
+
const missing: string[] = [];
|
|
640
|
+
const fetchable: string[] = [];
|
|
641
|
+
|
|
642
|
+
const chosenPresets: StylePreset[] = [];
|
|
643
|
+
for (const name of declaration.presets) {
|
|
644
|
+
const match = presets.find((preset) => preset.name === name);
|
|
645
|
+
if (match) chosenPresets.push(match);
|
|
646
|
+
else missing.push(name);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const chosenImports: ImportedStyle[] = [];
|
|
650
|
+
for (const id of declaration.imports) {
|
|
651
|
+
const match = imports.find((style) => style.id === id);
|
|
652
|
+
if (match) {
|
|
653
|
+
chosenImports.push(match);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
// The whole reason community ids are registry COORDINATES rather than the
|
|
657
|
+
// file's own `frontmatter.name`: this lookup happens on a machine that has
|
|
658
|
+
// fetched nothing, so the id has to resolve before any file exists.
|
|
659
|
+
if (
|
|
660
|
+
id.startsWith(COMMUNITY_ID_PREFIX) &&
|
|
661
|
+
findCommunityStyle(id.slice(COMMUNITY_ID_PREFIX.length))
|
|
662
|
+
) {
|
|
663
|
+
fetchable.push(id);
|
|
664
|
+
} else {
|
|
665
|
+
missing.push(id);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return {
|
|
670
|
+
sources: orderSources(chosenPresets, chosenImports),
|
|
671
|
+
missing,
|
|
672
|
+
fetchable,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Compare the committed declaration with what is actually live here.
|
|
678
|
+
*
|
|
679
|
+
* The comparison recomputes the hash from CURRENT source bodies rather than
|
|
680
|
+
* trusting the one stored in the declaration. That is the whole point: it
|
|
681
|
+
* answers "would applying this right now produce what I already have", which
|
|
682
|
+
* catches a pulled change, a plugin update, and a machine that never applied.
|
|
683
|
+
*/
|
|
684
|
+
export function computeStyleStatus(args: {
|
|
685
|
+
declaration: StyleDeclaration | null;
|
|
686
|
+
presets: StylePreset[];
|
|
687
|
+
imports: ImportedStyle[];
|
|
688
|
+
/** Hash recorded in the local generated style file, if it exists. */
|
|
689
|
+
localHash: string | null;
|
|
690
|
+
/** Whether settings.json actually points at our generated style. */
|
|
691
|
+
isActive: boolean;
|
|
692
|
+
}): StyleSyncStatus {
|
|
693
|
+
const { declaration, presets, imports, localHash, isActive } = args;
|
|
694
|
+
|
|
695
|
+
if (!declaration) {
|
|
696
|
+
return {
|
|
697
|
+
state: "undeclared",
|
|
698
|
+
detail: "no project style committed",
|
|
699
|
+
missing: [],
|
|
700
|
+
fetchable: [],
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const { sources, missing, fetchable } = resolveDeclaration(
|
|
705
|
+
declaration,
|
|
706
|
+
presets,
|
|
707
|
+
imports,
|
|
708
|
+
);
|
|
709
|
+
const expected = styleHash(sources);
|
|
710
|
+
|
|
711
|
+
// Said in preference to every other detail below, because it is the only one
|
|
712
|
+
// naming an action that CONVERGES. "press a" on a declaration whose community
|
|
713
|
+
// style is not on this machine re-applies a smaller set and leaves the
|
|
714
|
+
// difference in place, so the message would still be true next time.
|
|
715
|
+
const needsFetch =
|
|
716
|
+
fetchable.length > 0
|
|
717
|
+
? `project style needs ${fetchable.length} community style${
|
|
718
|
+
fetchable.length === 1 ? "" : "s"
|
|
719
|
+
} — press f`
|
|
720
|
+
: null;
|
|
721
|
+
|
|
722
|
+
if (!isActive || !localHash) {
|
|
723
|
+
return {
|
|
724
|
+
state: "not-applied",
|
|
725
|
+
detail: needsFetch ?? "project style not applied here — press a",
|
|
726
|
+
missing,
|
|
727
|
+
fetchable,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (localHash !== expected || needsFetch) {
|
|
731
|
+
// A pending fetch never reports in-sync even when the hashes agree. They
|
|
732
|
+
// agree because the un-fetched style was left out of BOTH sides, which is
|
|
733
|
+
// the composition matching itself rather than matching the declaration.
|
|
734
|
+
return {
|
|
735
|
+
state: "stale",
|
|
736
|
+
detail: needsFetch ?? "project style changed — press a to re-apply",
|
|
737
|
+
missing,
|
|
738
|
+
fetchable,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
state: "in-sync",
|
|
743
|
+
detail: "matches the project style",
|
|
744
|
+
missing,
|
|
745
|
+
fetchable,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ─── Paths ────────────────────────────────────────────────────────────────────
|
|
750
|
+
|
|
751
|
+
const DEFAULT_STYLE_NAME = "composed";
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The generated style's name for this project.
|
|
755
|
+
*
|
|
756
|
+
* Per-profile when a profile is active. Without this, two profiles both write
|
|
757
|
+
* `composed.md` and both point `outputStyle` at "composed": the second apply
|
|
758
|
+
* silently rewrites the first profile's style while its settings still claim
|
|
759
|
+
* the old one. Naming the file after the profile keeps them independent.
|
|
760
|
+
*/
|
|
761
|
+
export function generatedStyleName(profile: string | null): string {
|
|
762
|
+
if (!profile) return DEFAULT_STYLE_NAME;
|
|
763
|
+
const slug = profile
|
|
764
|
+
.toLowerCase()
|
|
765
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
766
|
+
.replace(/^-|-$/g, "");
|
|
767
|
+
return slug ? `${DEFAULT_STYLE_NAME}-${slug}` : DEFAULT_STYLE_NAME;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function stylePathFor(projectPath: string, styleName: string): string {
|
|
771
|
+
return path.join(projectPath, ".claude", "output-styles", `${styleName}.md`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function settingsPathFor(projectPath: string): string {
|
|
775
|
+
return path.join(projectPath, ".claude", "settings.json");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ─── Snapshot ─────────────────────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
/** Read the selection a previously generated style file recorded. */
|
|
781
|
+
export async function readApplied(stylePath: string): Promise<{
|
|
782
|
+
presets: string[];
|
|
783
|
+
imports: string[];
|
|
784
|
+
hash: string | null;
|
|
785
|
+
} | null> {
|
|
786
|
+
if (!(await fs.pathExists(stylePath))) return null;
|
|
787
|
+
const { frontmatter } = splitFrontmatter(
|
|
788
|
+
await fs.readFile(stylePath, "utf8"),
|
|
789
|
+
);
|
|
790
|
+
const clean = (value: string | undefined) =>
|
|
791
|
+
!value || value === "none" ? [] : splitList(value);
|
|
792
|
+
return {
|
|
793
|
+
presets: clean(frontmatter["style-presets"]),
|
|
794
|
+
imports: clean(frontmatter["style-imports"]),
|
|
795
|
+
// Absent on a file written before hashing existed; treated as "unknown",
|
|
796
|
+
// which reads as out-of-date and prompts a re-apply. That is the safe
|
|
797
|
+
// direction to be wrong in.
|
|
798
|
+
hash: frontmatter["style-hash"] || null,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function readOutputStyle(settingsPath: string): Promise<string | null> {
|
|
803
|
+
try {
|
|
804
|
+
if (!(await fs.pathExists(settingsPath))) return null;
|
|
805
|
+
const raw = (await fs.readFile(settingsPath, "utf8")).trim();
|
|
806
|
+
if (!raw) return null;
|
|
807
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
808
|
+
return typeof parsed.outputStyle === "string" ? parsed.outputStyle : null;
|
|
809
|
+
} catch {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export interface LoadStylesOptions {
|
|
815
|
+
/**
|
|
816
|
+
* Home directory to read user-scoped styles from. Defaults to the real one.
|
|
817
|
+
* Overridable so a test does not depend on whatever the developer happens to
|
|
818
|
+
* have in ~/.claude/output-styles.
|
|
819
|
+
*/
|
|
820
|
+
home?: string;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** One read of everything the Styles screen renders. */
|
|
824
|
+
export async function loadStyles(
|
|
825
|
+
projectPath: string,
|
|
826
|
+
options: LoadStylesOptions = {},
|
|
827
|
+
): Promise<StylesSnapshot> {
|
|
828
|
+
const home = options.home ?? os.homedir();
|
|
829
|
+
const profile = await activeProfile(projectPath);
|
|
830
|
+
const styleName = generatedStyleName(profile);
|
|
831
|
+
const stylePath = stylePathFor(projectPath, styleName);
|
|
832
|
+
const settingsPath = settingsPathFor(projectPath);
|
|
833
|
+
|
|
834
|
+
const presets = loadPresets();
|
|
835
|
+
// Exclude every name we might generate, not just the current one — a style
|
|
836
|
+
// left over from another profile is ours, not the user's, and importing it
|
|
837
|
+
// would nest a composition inside a composition.
|
|
838
|
+
const imports = await readImportable(
|
|
839
|
+
projectPath,
|
|
840
|
+
[DEFAULT_STYLE_NAME, styleName],
|
|
841
|
+
home,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
// `applied` means "these rules are in force", which takes BOTH the generated
|
|
845
|
+
// file recording them and settings.json pointing at that file. Reading the
|
|
846
|
+
// file alone reports a cleared style as still live, because clearing removes
|
|
847
|
+
// the pointer and deliberately leaves the file on disk.
|
|
848
|
+
const currentOutputStyle = await readOutputStyle(settingsPath);
|
|
849
|
+
const applied =
|
|
850
|
+
currentOutputStyle === styleName ? await readApplied(stylePath) : null;
|
|
851
|
+
|
|
852
|
+
const declaration = await readStyleDeclaration(projectPath);
|
|
853
|
+
const status = computeStyleStatus({
|
|
854
|
+
declaration,
|
|
855
|
+
presets,
|
|
856
|
+
imports,
|
|
857
|
+
localHash: applied?.hash ?? null,
|
|
858
|
+
isActive: currentOutputStyle === styleName,
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
return {
|
|
862
|
+
presets,
|
|
863
|
+
imports,
|
|
864
|
+
applied,
|
|
865
|
+
declaration,
|
|
866
|
+
status,
|
|
867
|
+
stylePath,
|
|
868
|
+
settingsPath,
|
|
869
|
+
styleName,
|
|
870
|
+
profile,
|
|
871
|
+
currentOutputStyle,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Why a selection cannot be applied. Empty array means it is valid.
|
|
879
|
+
*
|
|
880
|
+
* The rules are the plugin's, and they are not cosmetic: two verbosity presets
|
|
881
|
+
* are contradictory instructions that cancel out, producing a style that reads
|
|
882
|
+
* as if it were carefully configured and behaves as if it were not.
|
|
883
|
+
*/
|
|
884
|
+
export function validateSelection(chosen: StylePreset[]): string[] {
|
|
885
|
+
const errors: string[] = [];
|
|
886
|
+
|
|
887
|
+
for (const preset of chosen) {
|
|
888
|
+
if (preset.template) {
|
|
889
|
+
errors.push(
|
|
890
|
+
`"${preset.name}" is a template preset — fill it in and save it to .claude/output-styles/ first, then import it.`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const verbosity = chosen.filter((preset) => preset.axis === "verbosity");
|
|
896
|
+
if (verbosity.length > 1) {
|
|
897
|
+
errors.push(
|
|
898
|
+
`Pick exactly one verbosity preset. Got: ${verbosity
|
|
899
|
+
.map((preset) => preset.name)
|
|
900
|
+
.join(", ")}. They contradict each other and cancel out.`,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const seen = new Set<string>();
|
|
905
|
+
for (const preset of chosen) {
|
|
906
|
+
for (const conflict of preset.conflicts) {
|
|
907
|
+
if (!chosen.some((other) => other.name === conflict)) continue;
|
|
908
|
+
// Report a conflicting pair once, not once from each side.
|
|
909
|
+
const key = [preset.name, conflict].sort().join("|");
|
|
910
|
+
if (seen.has(key)) continue;
|
|
911
|
+
seen.add(key);
|
|
912
|
+
errors.push(`"${preset.name}" conflicts with "${conflict}".`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
return errors;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Order the selected sources for composition.
|
|
921
|
+
*
|
|
922
|
+
* Imports first, then verbosity, then modifiers — specific-after-broad, so a
|
|
923
|
+
* preset rule refines an imported personality rather than being buried by it.
|
|
924
|
+
*/
|
|
925
|
+
export function orderSources(
|
|
926
|
+
presets: StylePreset[],
|
|
927
|
+
imports: ImportedStyle[],
|
|
928
|
+
): StyleSource[] {
|
|
929
|
+
const verbosity = presets.filter((preset) => preset.axis === "verbosity");
|
|
930
|
+
const modifiers = presets.filter((preset) => preset.axis === "modifier");
|
|
931
|
+
return [...imports, ...verbosity.slice(0, 1), ...modifiers];
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ─── Composition ──────────────────────────────────────────────────────────────
|
|
935
|
+
|
|
936
|
+
/** Claude Code truncates long descriptions in the picker; stay well under. */
|
|
937
|
+
const MAX_DESCRIPTION = 200;
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* The shared integrity block. Appended to EVERY composition, unconditionally.
|
|
941
|
+
*
|
|
942
|
+
* A composed style is arbitrary instruction text assembled from several
|
|
943
|
+
* sources, and once the `community` origin lands one of those sources is
|
|
944
|
+
* third-party writing fetched from someone else's repository. We cannot detect
|
|
945
|
+
* prompt injection in it and will not pretend to. What we can do is state, at
|
|
946
|
+
* the end of the prompt, the things style is never allowed to change — so a
|
|
947
|
+
* rule that says "be brief" cannot be read as licence to trim a command, and a
|
|
948
|
+
* rule that says "no hedging" cannot be read as licence to drop a warning.
|
|
949
|
+
*
|
|
950
|
+
* It lives in the BODY, not the frontmatter, because only the body reaches the
|
|
951
|
+
* model. That is a real cost paid on every request, and it is the exception to
|
|
952
|
+
* the "provenance goes in frontmatter" rule directly above: provenance is for
|
|
953
|
+
* the human reading the file, this is for the model reading the prompt.
|
|
954
|
+
*
|
|
955
|
+
* It goes LAST for the same reason `orderSources` puts imports first —
|
|
956
|
+
* specific-after-broad, so later text refines earlier text rather than being
|
|
957
|
+
* buried by it.
|
|
958
|
+
*
|
|
959
|
+
* This is the only copy. It used to be duplicated in the retired style
|
|
960
|
+
* plugin's composer, with a test comparing the two; both are gone, so nothing
|
|
961
|
+
* can drift from it any more. It is still asserted to reach EVERY composition
|
|
962
|
+
* in `__tests__/styles-manager.test.ts` — that check is what matters, since
|
|
963
|
+
* the block is the security backstop on arbitrary assembled instruction text.
|
|
964
|
+
*/
|
|
965
|
+
export const INTEGRITY_BLOCK = `## Style limits
|
|
966
|
+
|
|
967
|
+
These rules override everything above. Style decides how an answer is worded;
|
|
968
|
+
it never decides what is true.
|
|
969
|
+
|
|
970
|
+
- Never reword, shorten, or tidy code, commands, file paths, identifiers, error
|
|
971
|
+
text, log output, or numbers to fit a style rule. Reproduce them exactly,
|
|
972
|
+
including the parts that read badly.
|
|
973
|
+
- A brevity rule may cut prose. It may never cut a flag from a command, a
|
|
974
|
+
segment from a path, a digit from a figure, or the line of a stack trace that
|
|
975
|
+
names the failure.
|
|
976
|
+
- Quote real output rather than paraphrasing it. When it is too long to
|
|
977
|
+
include, quote the part that decides the answer and say what was left out.
|
|
978
|
+
- Never soften or drop a security warning, a data-loss risk, or a caveat that
|
|
979
|
+
would change what the reader does next. State it plainly, even under a rule
|
|
980
|
+
that bans hedging.
|
|
981
|
+
- Ask before any destructive or irreversible action and name exactly what would
|
|
982
|
+
be lost. No verbosity or brevity rule suppresses that confirmation.
|
|
983
|
+
- Say when something is unverified, failing, or unknown. A rule against filler
|
|
984
|
+
bans padding, not honesty.`;
|
|
985
|
+
|
|
986
|
+
function describe(sources: StyleSource[]): string {
|
|
987
|
+
const names = sources.map((source) => source.name).join(", ");
|
|
988
|
+
const text = `Composed communication style: ${names || "no rules selected"}`;
|
|
989
|
+
const clipped =
|
|
990
|
+
text.length > MAX_DESCRIPTION
|
|
991
|
+
? `${text.slice(0, MAX_DESCRIPTION - 1)}\u2026`
|
|
992
|
+
: text;
|
|
993
|
+
// The value carries ": " from the label and preset names may carry anything.
|
|
994
|
+
// Unquoted, YAML reads that as a nested mapping and the frontmatter fails to
|
|
995
|
+
// parse — so always quote, and escape what would close the quote.
|
|
996
|
+
return `"${clipped.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Render the single generated output-style file for a selection. */
|
|
1000
|
+
export function composeStyleFile(
|
|
1001
|
+
styleName: string,
|
|
1002
|
+
sources: StyleSource[],
|
|
1003
|
+
): string {
|
|
1004
|
+
const presets = sources.filter(
|
|
1005
|
+
(source): source is StylePreset => source.kind === "preset",
|
|
1006
|
+
);
|
|
1007
|
+
const imports = sources.filter(
|
|
1008
|
+
(source): source is ImportedStyle => source.kind === "imported",
|
|
1009
|
+
);
|
|
1010
|
+
|
|
1011
|
+
// Provenance goes in FRONTMATTER, not the body: Claude Code splits the file
|
|
1012
|
+
// and only the body becomes the prompt, so a marker in the body is paid for
|
|
1013
|
+
// on every request forever.
|
|
1014
|
+
const lines: string[] = [
|
|
1015
|
+
"---",
|
|
1016
|
+
`name: ${styleName}`,
|
|
1017
|
+
`description: ${describe(sources)}`,
|
|
1018
|
+
// Without this, Claude Code drops its own coding-discipline block from the
|
|
1019
|
+
// system prompt. A style about how to COMMUNICATE has no business
|
|
1020
|
+
// switching off how to write code.
|
|
1021
|
+
"keep-coding-instructions: true",
|
|
1022
|
+
'generated-by: "magus Styles tab. Hand edits are lost on the next apply."',
|
|
1023
|
+
`style-presets: ${presets.map((preset) => preset.name).join(", ") || "none"}`,
|
|
1024
|
+
`style-imports: ${imports.map((style) => style.id).join(", ") || "none"}`,
|
|
1025
|
+
// Fingerprint of the rules this file was built from. Recomputing it from
|
|
1026
|
+
// the declaration and comparing tells a teammate whether their local copy
|
|
1027
|
+
// is what the project currently asks for — see computeStyleStatus.
|
|
1028
|
+
`style-hash: ${styleHash(sources)}`,
|
|
1029
|
+
"---",
|
|
1030
|
+
"",
|
|
1031
|
+
];
|
|
1032
|
+
|
|
1033
|
+
for (const style of imports) {
|
|
1034
|
+
lines.push(`## Imported: ${style.name}`, "", style.body, "");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (presets.length > 0) {
|
|
1038
|
+
lines.push("## Communication style", "");
|
|
1039
|
+
for (const preset of presets) {
|
|
1040
|
+
lines.push(preset.body, "");
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Unconditional, and last. A composition with no sources still gets it —
|
|
1045
|
+
// there is no selection for which "do not rewrite an error message" stops
|
|
1046
|
+
// applying.
|
|
1047
|
+
lines.push(INTEGRITY_BLOCK, "");
|
|
1048
|
+
|
|
1049
|
+
return `${lines
|
|
1050
|
+
.join("\n")
|
|
1051
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
1052
|
+
.trimEnd()}\n`;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// ─── Apply ────────────────────────────────────────────────────────────────────
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Set `outputStyle` in settings.json, preserving everything else.
|
|
1059
|
+
*
|
|
1060
|
+
* Writes in place, and refuses a file it cannot parse — both are
|
|
1061
|
+
* `updateSettingsFile`'s contract, and both are documented there.
|
|
1062
|
+
*/
|
|
1063
|
+
async function setOutputStyle(
|
|
1064
|
+
settingsPath: string,
|
|
1065
|
+
styleName: string,
|
|
1066
|
+
): Promise<void> {
|
|
1067
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
1068
|
+
settings.outputStyle = styleName;
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Record the style in the committed manifest so it survives re-materialization.
|
|
1074
|
+
* Returns false when there is no active profile or no manifest entry for it —
|
|
1075
|
+
* both are normal, and neither is an error.
|
|
1076
|
+
*
|
|
1077
|
+
* `mergeManifestSettings` is shared with model routing, which writes the same
|
|
1078
|
+
* `profiles.<name>.settings` for the same reason. It throws on a manifest it
|
|
1079
|
+
* cannot parse rather than reporting a success it did not achieve.
|
|
1080
|
+
*/
|
|
1081
|
+
async function recordInManifest(
|
|
1082
|
+
projectPath: string,
|
|
1083
|
+
profile: string | null,
|
|
1084
|
+
styleName: string,
|
|
1085
|
+
): Promise<boolean> {
|
|
1086
|
+
return mergeManifestSettings(projectPath, profile, {
|
|
1087
|
+
outputStyle: styleName,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
export interface ApplyStylesArgs {
|
|
1092
|
+
projectPath: string;
|
|
1093
|
+
presets: StylePreset[];
|
|
1094
|
+
imports: ImportedStyle[];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Compose the selection, write it, activate it, and verify by re-reading.
|
|
1099
|
+
*
|
|
1100
|
+
* Verification is deliberate: a write that reported success but produced a
|
|
1101
|
+
* style file with `keep-coding-instructions` missing would strip Claude Code's
|
|
1102
|
+
* coding rules, and nothing downstream would notice.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function applyStyles({
|
|
1105
|
+
projectPath,
|
|
1106
|
+
presets,
|
|
1107
|
+
imports,
|
|
1108
|
+
}: ApplyStylesArgs): Promise<ApplyResult> {
|
|
1109
|
+
const errors = validateSelection(presets);
|
|
1110
|
+
if (errors.length > 0) throw new Error(errors.join(" "));
|
|
1111
|
+
if (presets.length === 0 && imports.length === 0) {
|
|
1112
|
+
throw new Error("Nothing selected. Pick at least one style.");
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const profile = await activeProfile(projectPath);
|
|
1116
|
+
const styleName = generatedStyleName(profile);
|
|
1117
|
+
const stylePath = stylePathFor(projectPath, styleName);
|
|
1118
|
+
const settingsPath = settingsPathFor(projectPath);
|
|
1119
|
+
|
|
1120
|
+
const sources = orderSources(presets, imports);
|
|
1121
|
+
const contents = composeStyleFile(styleName, sources);
|
|
1122
|
+
|
|
1123
|
+
await fs.ensureDir(path.dirname(stylePath));
|
|
1124
|
+
await fs.writeFile(stylePath, contents, "utf8");
|
|
1125
|
+
await setOutputStyle(settingsPath, styleName);
|
|
1126
|
+
|
|
1127
|
+
// Verify by re-reading, not by trusting the write.
|
|
1128
|
+
const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
|
|
1129
|
+
const problems: string[] = [];
|
|
1130
|
+
if (verify.frontmatter.name !== styleName) {
|
|
1131
|
+
problems.push(
|
|
1132
|
+
`style file name is "${verify.frontmatter.name}", expected "${styleName}"`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
if (asBool(verify.frontmatter["keep-coding-instructions"]) !== true) {
|
|
1136
|
+
problems.push(
|
|
1137
|
+
"keep-coding-instructions is not true — coding rules would be dropped",
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
if ((await readOutputStyle(settingsPath)) !== styleName) {
|
|
1141
|
+
problems.push("settings outputStyle did not take");
|
|
1142
|
+
}
|
|
1143
|
+
if (problems.length > 0) {
|
|
1144
|
+
throw new Error(`Verification failed — ${problems.join("; ")}`);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const recordedInManifest = await recordInManifest(
|
|
1148
|
+
projectPath,
|
|
1149
|
+
profile,
|
|
1150
|
+
styleName,
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
// Write the declaration LAST, once the artifact is verified. It is the thing
|
|
1154
|
+
// that gets committed and that teammates are told to match, so it must never
|
|
1155
|
+
// claim a composition that failed to produce a valid file.
|
|
1156
|
+
const hash = styleHash(sources);
|
|
1157
|
+
const declaration: StyleDeclaration = {
|
|
1158
|
+
version: 1,
|
|
1159
|
+
presets: presets.map((preset) => preset.name),
|
|
1160
|
+
imports: imports.map((style) => style.id),
|
|
1161
|
+
hash,
|
|
1162
|
+
updatedAt: new Date().toISOString(),
|
|
1163
|
+
};
|
|
1164
|
+
await writeStyleDeclaration(projectPath, declaration);
|
|
1165
|
+
|
|
1166
|
+
return {
|
|
1167
|
+
stylePath,
|
|
1168
|
+
settingsPath,
|
|
1169
|
+
styleName,
|
|
1170
|
+
profile,
|
|
1171
|
+
recordedInManifest,
|
|
1172
|
+
declarationPath: declarationPath(projectPath),
|
|
1173
|
+
hash,
|
|
1174
|
+
presets: declaration.presets,
|
|
1175
|
+
imports: declaration.imports,
|
|
1176
|
+
bytes: Buffer.byteLength(contents, "utf8"),
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ─── Authoring a team style ───────────────────────────────────────────────────
|
|
1181
|
+
|
|
1182
|
+
/** Filename-safe slug. Rejects anything that would escape the styles dir. */
|
|
1183
|
+
export function styleSlug(input: string): string {
|
|
1184
|
+
return input
|
|
1185
|
+
.trim()
|
|
1186
|
+
.toLowerCase()
|
|
1187
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1188
|
+
.replace(/^-+|-+$/g, "");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
export interface CreatedStyle {
|
|
1192
|
+
path: string;
|
|
1193
|
+
name: string;
|
|
1194
|
+
/** True when the file already existed and was left untouched. */
|
|
1195
|
+
existed: boolean;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Scaffold a project-scoped style the team can edit and commit.
|
|
1200
|
+
*
|
|
1201
|
+
* Project scope is the whole point: `<project>/.claude/output-styles/` lives in
|
|
1202
|
+
* the repository, so the style travels with the project instead of being one
|
|
1203
|
+
* person's machine-local preference.
|
|
1204
|
+
*
|
|
1205
|
+
* This writes a starting file rather than opening an editor — magus owns the
|
|
1206
|
+
* terminal, and suspending an OpenTUI app to hand the TTY to `$EDITOR` is a
|
|
1207
|
+
* reliable way to leave the terminal in a broken state. The caller shows the
|
|
1208
|
+
* path; the user edits it and presses `r`.
|
|
1209
|
+
*
|
|
1210
|
+
* An existing file is never overwritten: that would silently destroy work the
|
|
1211
|
+
* team had already committed.
|
|
1212
|
+
*/
|
|
1213
|
+
export async function createTeamStyle(
|
|
1214
|
+
projectPath: string,
|
|
1215
|
+
rawName: string,
|
|
1216
|
+
): Promise<CreatedStyle> {
|
|
1217
|
+
const name = styleSlug(rawName);
|
|
1218
|
+
if (!name) {
|
|
1219
|
+
throw new Error(
|
|
1220
|
+
`"${rawName}" has no letters or digits to make a filename from.`,
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
if (
|
|
1224
|
+
name === DEFAULT_STYLE_NAME ||
|
|
1225
|
+
name.startsWith(`${DEFAULT_STYLE_NAME}-`)
|
|
1226
|
+
) {
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
`"${name}" is the name magus generates. Pick another, or it would be overwritten on the next apply.`,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const file = path.join(projectPath, ".claude", "output-styles", `${name}.md`);
|
|
1233
|
+
if (await fs.pathExists(file)) {
|
|
1234
|
+
return { path: file, name, existed: true };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const scaffold = [
|
|
1238
|
+
"---",
|
|
1239
|
+
`name: ${name}`,
|
|
1240
|
+
`description: "Team communication style: ${name}"`,
|
|
1241
|
+
// Same reasoning as the composed file: a style about how to COMMUNICATE
|
|
1242
|
+
// must not switch off Claude Code's own coding rules.
|
|
1243
|
+
"keep-coding-instructions: true",
|
|
1244
|
+
"---",
|
|
1245
|
+
"",
|
|
1246
|
+
`## ${name}`,
|
|
1247
|
+
"",
|
|
1248
|
+
"Replace this with the rules for this project. Write them as short,",
|
|
1249
|
+
"checkable instructions — each one something a reader could tell was",
|
|
1250
|
+
"followed or not. For example:",
|
|
1251
|
+
"",
|
|
1252
|
+
"- Name the file and line when describing a change.",
|
|
1253
|
+
"- Report a failing test with its actual output, never a paraphrase.",
|
|
1254
|
+
"",
|
|
1255
|
+
"Commit this file with the project so everyone gets the same style.",
|
|
1256
|
+
"",
|
|
1257
|
+
].join("\n");
|
|
1258
|
+
|
|
1259
|
+
await fs.ensureDir(path.dirname(file));
|
|
1260
|
+
await fs.writeFile(file, scaffold, "utf8");
|
|
1261
|
+
return { path: file, name, existed: false };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Deactivate: drop `outputStyle` from settings.json (and the manifest entry).
|
|
1266
|
+
* The generated file is left on disk — it is cheap, and keeping it means an
|
|
1267
|
+
* accidental clear is one re-apply away from being undone.
|
|
1268
|
+
*/
|
|
1269
|
+
export async function clearStyle(projectPath: string): Promise<void> {
|
|
1270
|
+
const settingsPath = settingsPathFor(projectPath);
|
|
1271
|
+
// The guard stays: a project that never had a style must not gain a
|
|
1272
|
+
// settings.json just because the style was cleared.
|
|
1273
|
+
if (await fs.pathExists(settingsPath)) {
|
|
1274
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
1275
|
+
// The key must be ABSENT, not present-and-undefined: Claude Code reads
|
|
1276
|
+
// presence, and an `= undefined` assignment still answers `in` checks.
|
|
1277
|
+
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1278
|
+
delete settings.outputStyle;
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const profile = await activeProfile(projectPath);
|
|
1283
|
+
if (!profile) return;
|
|
1284
|
+
if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
|
|
1285
|
+
const manifest = await readManifest(projectPath);
|
|
1286
|
+
const entry = manifest.profiles[profile];
|
|
1287
|
+
if (!entry?.settings || !("outputStyle" in entry.settings)) return;
|
|
1288
|
+
// The guard above uses `in`, so assigning undefined would make a second call
|
|
1289
|
+
// a no-op that still leaves the key serialized into profiles.json.
|
|
1290
|
+
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1291
|
+
delete entry.settings.outputStyle;
|
|
1292
|
+
entry.updatedAt = new Date().toISOString();
|
|
1293
|
+
await writeManifest(manifest, projectPath);
|
|
1294
|
+
}
|