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
package/src/cli/ansi.ts
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ansi.ts β colour, colour maths and one-row widgets for the printed commands.
|
|
3
|
+
*
|
|
4
|
+
* The interactive TUI draws through OpenTUI. `magus update`, `install` and
|
|
5
|
+
* `doctor` cannot: they print into the user's scrollback, interleave readline
|
|
6
|
+
* prompts, and hand the terminal to subprocesses that inherit stdio. Mounting a
|
|
7
|
+
* renderer around that fights all three. So the same visual language is rebuilt
|
|
8
|
+
* here on raw escape sequences β one blended colour per meter cell, badges with
|
|
9
|
+
* ink we own, dim chrome and saturated signal.
|
|
10
|
+
*
|
|
11
|
+
* PALETTE PARITY IS THE POINT. Every hue comes from `ui/theme.ts`'s `brand`, so
|
|
12
|
+
* a colour means one thing across the whole product: amber is "behind" in the
|
|
13
|
+
* TUI's plugin list and in an update plan alike. Nothing here invents a hue.
|
|
14
|
+
*
|
|
15
|
+
* The two `ramps` are the one place brand tokens are transformed, and they say
|
|
16
|
+
* why: `brand` is tuned to clear 3:1 as INK on both light and dark terminals,
|
|
17
|
+
* which makes it mid-dark. A solid block is a UI component, not text β WCAG's
|
|
18
|
+
* text ratio does not apply to it, and mid-dark blocks read as mud. So the fill
|
|
19
|
+
* ramps lighten those tokens. Ink colours are never lightened.
|
|
20
|
+
*
|
|
21
|
+
* No OpenTUI type crosses this boundary: hex in, escaped string out.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { barTrack } from "../ui/theme-mode.js";
|
|
25
|
+
import { brand } from "../ui/theme.js";
|
|
26
|
+
|
|
27
|
+
// -- colour capability -------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Test seam. `null` restores environment detection. */
|
|
30
|
+
let colorOverride: boolean | null = null;
|
|
31
|
+
|
|
32
|
+
/** Force colour on or off; pass null to go back to detecting it. Tests only. */
|
|
33
|
+
export function setColorEnabled(value: boolean | null): void {
|
|
34
|
+
colorOverride = value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether to emit escape sequences at all.
|
|
39
|
+
*
|
|
40
|
+
* Order matters: NO_COLOR (any non-empty value, per no-color.org) beats
|
|
41
|
+
* FORCE_COLOR, which beats TTY detection. The other order would hand colour to
|
|
42
|
+
* a user who exported NO_COLOR in their shell profile the moment they ran under
|
|
43
|
+
* a CI that sets FORCE_COLOR.
|
|
44
|
+
*/
|
|
45
|
+
export function colorEnabled(): boolean {
|
|
46
|
+
if (colorOverride !== null) return colorOverride;
|
|
47
|
+
const no = process.env.NO_COLOR;
|
|
48
|
+
if (no !== undefined && no !== "") return false;
|
|
49
|
+
const force = process.env.FORCE_COLOR;
|
|
50
|
+
if (force !== undefined) return force !== "0" && force !== "false";
|
|
51
|
+
if (process.env.TERM === "dumb") return false;
|
|
52
|
+
return process.stdout.isTTY === true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// -- hex and channels --------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const clamp01 = (n: number): number =>
|
|
58
|
+
Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
|
|
59
|
+
|
|
60
|
+
/** `#rgb` and `#rrggbb`, hash optional. Anything else resolves to black. */
|
|
61
|
+
function parseHex(hex: string): [number, number, number] {
|
|
62
|
+
let h = hex.trim().replace(/^#/, "");
|
|
63
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
64
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return [0, 0, 0];
|
|
65
|
+
return [
|
|
66
|
+
Number.parseInt(h.slice(0, 2), 16),
|
|
67
|
+
Number.parseInt(h.slice(2, 4), 16),
|
|
68
|
+
Number.parseInt(h.slice(4, 6), 16),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const toHex = (c: [number, number, number]): string =>
|
|
73
|
+
`#${c
|
|
74
|
+
.map((n) =>
|
|
75
|
+
Math.round(Math.min(255, Math.max(0, n)))
|
|
76
|
+
.toString(16)
|
|
77
|
+
.padStart(2, "0"),
|
|
78
|
+
)
|
|
79
|
+
.join("")}`;
|
|
80
|
+
|
|
81
|
+
// -- colour maths ------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Linear interpolation in sRGB channel space, `t` clamped to 0..1.
|
|
85
|
+
*
|
|
86
|
+
* sRGB is a deliberate choice with a known cost: a two-stop blend between
|
|
87
|
+
* distant hues desaturates through the middle. Both ramps below therefore carry
|
|
88
|
+
* an explicit midtone stop rather than trusting the interpolator to find a good
|
|
89
|
+
* one β a chosen control point beats any colour space.
|
|
90
|
+
*/
|
|
91
|
+
export function mix(from: string, to: string, t: number): string {
|
|
92
|
+
const k = clamp01(t);
|
|
93
|
+
const a = parseHex(from);
|
|
94
|
+
const b = parseHex(to);
|
|
95
|
+
return toHex([
|
|
96
|
+
a[0] + (b[0] - a[0]) * k,
|
|
97
|
+
a[1] + (b[1] - a[1]) * k,
|
|
98
|
+
a[2] + (b[2] - a[2]) * k,
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `steps` colours from `from` to `to`, BOTH ENDPOINTS INCLUSIVE.
|
|
104
|
+
*
|
|
105
|
+
* out.length === steps, out[0] === from, out[steps - 1] === to
|
|
106
|
+
*
|
|
107
|
+
* `steps === 1` takes the `from` end, so there is no division by zero. Zero,
|
|
108
|
+
* negative and non-integer all give `[]`. Call it with the widget's real width:
|
|
109
|
+
* one colour per cell is what reads as continuous.
|
|
110
|
+
*/
|
|
111
|
+
export function blend1D(steps: number, from: string, to: string): string[] {
|
|
112
|
+
if (!Number.isInteger(steps) || steps <= 0) return [];
|
|
113
|
+
if (steps === 1) return [toHex(parseHex(from))];
|
|
114
|
+
return Array.from({ length: steps }, (_, i) =>
|
|
115
|
+
mix(from, to, i / (steps - 1)),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Multi-stop ramp: `steps` colours spread evenly across every stop, in order. */
|
|
120
|
+
export function blendStops(steps: number, ...stops: string[]): string[] {
|
|
121
|
+
if (!Number.isInteger(steps) || steps <= 0 || stops.length === 0) return [];
|
|
122
|
+
const first = stops[0];
|
|
123
|
+
const last = stops[stops.length - 1];
|
|
124
|
+
if (stops.length === 1 || steps === 1) return blend1D(steps, first, last);
|
|
125
|
+
const segs = stops.length - 1;
|
|
126
|
+
return Array.from({ length: steps }, (_, i) => {
|
|
127
|
+
const p = (i / (steps - 1)) * segs;
|
|
128
|
+
const s = Math.min(segs - 1, Math.floor(p));
|
|
129
|
+
return mix(stops[s], stops[s + 1], p - s);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Scale toward black by `amount` (0..1). */
|
|
134
|
+
export function darken(hex: string, amount: number): string {
|
|
135
|
+
const k = 1 - clamp01(amount);
|
|
136
|
+
const [r, g, b] = parseHex(hex);
|
|
137
|
+
return toHex([r * k, g * k, b * k]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Scale toward white by `amount` (0..1). */
|
|
141
|
+
export function lighten(hex: string, amount: number): string {
|
|
142
|
+
const k = clamp01(amount);
|
|
143
|
+
const [r, g, b] = parseHex(hex);
|
|
144
|
+
return toHex([r + (255 - r) * k, g + (255 - g) * k, b + (255 - b) * k]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Ink that stays readable on a filled block of `hex`.
|
|
149
|
+
*
|
|
150
|
+
* Perceived luminance, not the channel average: green contributes nearly six
|
|
151
|
+
* times what blue does, so an average puts white ink on amber and loses it.
|
|
152
|
+
*/
|
|
153
|
+
export function inkOn(hex: string): string {
|
|
154
|
+
const [r, g, b] = parseHex(hex);
|
|
155
|
+
return 0.299 * r + 0.587 * g + 0.114 * b > 140 ? "#101014" : brand.ink;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// -- styling -----------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
const RESET = "\x1b[0m";
|
|
161
|
+
|
|
162
|
+
/** Paint `text` in `hex` as foreground. Returns `text` unchanged when off. */
|
|
163
|
+
export function fg(hex: string, text: string): string {
|
|
164
|
+
if (!colorEnabled()) return text;
|
|
165
|
+
const [r, g, b] = parseHex(hex);
|
|
166
|
+
return `\x1b[38;2;${r};${g};${b}m${text}${RESET}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Paint `text` on a `hex` background. Returns `text` unchanged when off. */
|
|
170
|
+
export function bg(hex: string, text: string): string {
|
|
171
|
+
if (!colorEnabled()) return text;
|
|
172
|
+
const [r, g, b] = parseHex(hex);
|
|
173
|
+
return `\x1b[48;2;${r};${g};${b}m${text}${RESET}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Both in one sequence, so nothing nested can reset half a badge. */
|
|
177
|
+
export function on(bgHex: string, fgHex: string, text: string): string {
|
|
178
|
+
if (!colorEnabled()) return text;
|
|
179
|
+
const [br, bgn, bb] = parseHex(bgHex);
|
|
180
|
+
const [fr, fgn, fb] = parseHex(fgHex);
|
|
181
|
+
return `\x1b[48;2;${br};${bgn};${bb};38;2;${fr};${fgn};${fb}m${text}${RESET}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function bold(text: string): string {
|
|
185
|
+
return colorEnabled() ? `\x1b[1m${text}${RESET}` : text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** De-emphasis by colour, not by SGR 2 β faint is unreliable across terminals. */
|
|
189
|
+
export function dim(text: string): string {
|
|
190
|
+
return fg(brand.muted, text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -- measuring ---------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
/** Every CSI sequence, not only SGR β the live region emits cursor moves too. */
|
|
196
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
|
|
197
|
+
const CSI = /\x1b\[[0-9;?]*[\x20-\x2f]*[\x40-\x7e]/g;
|
|
198
|
+
|
|
199
|
+
export function stripAnsi(text: string): string {
|
|
200
|
+
return text.replace(CSI, "");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Cells one codepoint occupies: 0, 1 or 2.
|
|
205
|
+
*
|
|
206
|
+
* UNDER-COUNTING IS THE DANGEROUS DIRECTION. A line measured narrower than it
|
|
207
|
+
* renders reaches the terminal's last column, auto-wraps into a second row, and
|
|
208
|
+
* then the live region's cursor-up erase β which counted it as one row β starts
|
|
209
|
+
* inside the block and smears it down the screen. Over-counting only clips a
|
|
210
|
+
* character early. So where a range is genuinely in doubt, widen.
|
|
211
|
+
*
|
|
212
|
+
* The ranges are East Asian Wide plus the emoji blocks that render double in
|
|
213
|
+
* every terminal worth supporting. EAW-AMBIGUOUS characters (most of the
|
|
214
|
+
* U+2500 box-drawing and arrow range, including `β` and `β` used in the update
|
|
215
|
+
* report) are deliberately counted as 1: terminals genuinely disagree, and
|
|
216
|
+
* treating them as wide would misalign the common, correct case.
|
|
217
|
+
*/
|
|
218
|
+
function charWidth(cp: number): number {
|
|
219
|
+
// Combining marks, variation selectors, and the zero-width joiner take no
|
|
220
|
+
// cell. ZWJ matters: without it a three-person emoji family measures as
|
|
221
|
+
// three separate wide glyphs instead of one.
|
|
222
|
+
if (
|
|
223
|
+
(cp >= 0x0300 && cp <= 0x036f) ||
|
|
224
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
225
|
+
cp === 0x200d ||
|
|
226
|
+
cp === 0xfeff
|
|
227
|
+
)
|
|
228
|
+
return 0;
|
|
229
|
+
if (
|
|
230
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
231
|
+
// Individually-Wide dingbats and symbols scattered through U+2000-27BF.
|
|
232
|
+
cp === 0x231a ||
|
|
233
|
+
cp === 0x231b ||
|
|
234
|
+
(cp >= 0x23e9 && cp <= 0x23ec) ||
|
|
235
|
+
cp === 0x23f0 ||
|
|
236
|
+
cp === 0x23f3 ||
|
|
237
|
+
(cp >= 0x25fd && cp <= 0x25fe) ||
|
|
238
|
+
(cp >= 0x2614 && cp <= 0x2615) ||
|
|
239
|
+
(cp >= 0x2648 && cp <= 0x2653) ||
|
|
240
|
+
cp === 0x267f ||
|
|
241
|
+
cp === 0x2693 ||
|
|
242
|
+
cp === 0x26a1 ||
|
|
243
|
+
(cp >= 0x26aa && cp <= 0x26ab) ||
|
|
244
|
+
(cp >= 0x26bd && cp <= 0x26be) ||
|
|
245
|
+
(cp >= 0x26c4 && cp <= 0x26c5) ||
|
|
246
|
+
cp === 0x26ce ||
|
|
247
|
+
cp === 0x26d4 ||
|
|
248
|
+
cp === 0x26ea ||
|
|
249
|
+
(cp >= 0x26f2 && cp <= 0x26f3) ||
|
|
250
|
+
cp === 0x26f5 ||
|
|
251
|
+
cp === 0x26fa ||
|
|
252
|
+
cp === 0x26fd ||
|
|
253
|
+
cp === 0x2705 ||
|
|
254
|
+
(cp >= 0x270a && cp <= 0x270b) ||
|
|
255
|
+
cp === 0x2728 ||
|
|
256
|
+
cp === 0x274c ||
|
|
257
|
+
cp === 0x274e ||
|
|
258
|
+
(cp >= 0x2753 && cp <= 0x2755) ||
|
|
259
|
+
cp === 0x2757 ||
|
|
260
|
+
(cp >= 0x2795 && cp <= 0x2797) ||
|
|
261
|
+
cp === 0x27b0 ||
|
|
262
|
+
cp === 0x27bf ||
|
|
263
|
+
(cp >= 0x2b1b && cp <= 0x2b1c) ||
|
|
264
|
+
cp === 0x2b50 ||
|
|
265
|
+
cp === 0x2b55 ||
|
|
266
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
267
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
268
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
269
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
270
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
271
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
272
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
273
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
274
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
275
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
276
|
+
// Emoji planes, contiguous rather than the three islands this used to
|
|
277
|
+
// carry β the gaps (transport at U+1F680, flags at U+1F1E6) all render
|
|
278
|
+
// wide, so excluding them was pure under-count.
|
|
279
|
+
(cp >= 0x1f000 && cp <= 0x1f9ff) ||
|
|
280
|
+
(cp >= 0x1fa70 && cp <= 0x1faff) ||
|
|
281
|
+
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
282
|
+
)
|
|
283
|
+
return 2;
|
|
284
|
+
return 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Visible cell width, ignoring escape sequences. */
|
|
288
|
+
export function width(text: string): number {
|
|
289
|
+
let w = 0;
|
|
290
|
+
for (const ch of stripAnsi(text)) w += charWidth(ch.codePointAt(0) ?? 0);
|
|
291
|
+
return w;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Pad on the right to `n` visible cells. Never truncates. */
|
|
295
|
+
export function padEnd(text: string, n: number): string {
|
|
296
|
+
const w = width(text);
|
|
297
|
+
return w >= n ? text : text + " ".repeat(n - w);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Pad on the left to `n` visible cells β the only safe padding for numerals. */
|
|
301
|
+
export function padStart(text: string, n: number): string {
|
|
302
|
+
const w = width(text);
|
|
303
|
+
return w >= n ? text : " ".repeat(n - w) + text;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Clip to `n` visible cells, stepping over escape sequences rather than
|
|
308
|
+
* counting them.
|
|
309
|
+
*
|
|
310
|
+
* The live region depends on this. A line one cell too wide occupies two
|
|
311
|
+
* terminal rows while the repainter still counts it as one, so the cursor-up
|
|
312
|
+
* arithmetic drifts by a row per frame and the block smears down the screen.
|
|
313
|
+
*/
|
|
314
|
+
export function truncate(text: string, n: number): string {
|
|
315
|
+
if (n <= 0) return "";
|
|
316
|
+
if (width(text) <= n) return text;
|
|
317
|
+
let out = "";
|
|
318
|
+
let w = 0;
|
|
319
|
+
let i = 0;
|
|
320
|
+
while (i < text.length) {
|
|
321
|
+
CSI.lastIndex = i;
|
|
322
|
+
const m = CSI.exec(text);
|
|
323
|
+
if (m && m.index === i) {
|
|
324
|
+
out += m[0];
|
|
325
|
+
i += m[0].length;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const cp = text.codePointAt(i);
|
|
329
|
+
if (cp === undefined) break;
|
|
330
|
+
const ch = String.fromCodePoint(cp);
|
|
331
|
+
const cw = charWidth(cp);
|
|
332
|
+
if (w + cw > n) break;
|
|
333
|
+
out += ch;
|
|
334
|
+
w += cw;
|
|
335
|
+
i += ch.length;
|
|
336
|
+
}
|
|
337
|
+
return out + (colorEnabled() ? RESET : "");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -- palette derived for fills -----------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Gradient stops for filled blocks. Lightened brand tokens β the file header
|
|
344
|
+
* says why ink hues and fill hues cannot be the same values.
|
|
345
|
+
*/
|
|
346
|
+
export const ramps = {
|
|
347
|
+
/**
|
|
348
|
+
* Work in progress to done. Purple is magus's brand, green is "settled",
|
|
349
|
+
* and the blue midtone is the chosen control point that stops a two-hue
|
|
350
|
+
* blend desaturating into grey at the halfway mark.
|
|
351
|
+
*/
|
|
352
|
+
progress: [
|
|
353
|
+
lighten(brand.accent, 0.15),
|
|
354
|
+
lighten(brand.link, 0.2),
|
|
355
|
+
lighten(brand.success, 0.25),
|
|
356
|
+
],
|
|
357
|
+
/**
|
|
358
|
+
* Severity: green through amber to red, for any magnitude that is bad news
|
|
359
|
+
* as it grows β a step's share of the run's wall clock, a count of items
|
|
360
|
+
* behind. Same three hues as the badges, so "amber means look at this" holds
|
|
361
|
+
* whether it is a bar or a chip.
|
|
362
|
+
*/
|
|
363
|
+
severity: [
|
|
364
|
+
lighten(brand.success, 0.25),
|
|
365
|
+
lighten(brand.warning, 0.2),
|
|
366
|
+
lighten(brand.danger, 0.15),
|
|
367
|
+
],
|
|
368
|
+
} as const;
|
|
369
|
+
|
|
370
|
+
// -- widgets -----------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const FILL = "β";
|
|
373
|
+
const TRACK = "β";
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The unfilled part of a bar, resolved per page.
|
|
377
|
+
*
|
|
378
|
+
* At full `brand.muted` a 20-cell `β` track reads as hatched texture rather
|
|
379
|
+
* than as emptiness, and competes with the fill β the opposite of "dim the
|
|
380
|
+
* chrome, saturate the signal". A track is chrome.
|
|
381
|
+
*
|
|
382
|
+
* Which grey is quiet depends on the page, so the two values and the numbers
|
|
383
|
+
* behind them live in `ui/theme-mode.ts`. Resolved per CALL, never hoisted into
|
|
384
|
+
* a const: the mode is set at startup by the router, after this module loads.
|
|
385
|
+
*/
|
|
386
|
+
const TRACK_COLOR = (): string => barTrack();
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Gradient meter: `cells` wide, one blended colour per filled cell.
|
|
390
|
+
*
|
|
391
|
+
* `pct` is 0..100, never 0..1 β the single most common way this widget goes
|
|
392
|
+
* silently wrong. The track glyph is the same width as the fill glyph, so the
|
|
393
|
+
* row cannot change length as it fills.
|
|
394
|
+
*/
|
|
395
|
+
export function meter(
|
|
396
|
+
pct: number,
|
|
397
|
+
cells: number,
|
|
398
|
+
stops: readonly string[] = ramps.progress,
|
|
399
|
+
): string {
|
|
400
|
+
if (cells <= 0) return "";
|
|
401
|
+
const p = clamp01((Number.isFinite(pct) ? pct : 0) / 100);
|
|
402
|
+
const filled = Math.round(p * cells);
|
|
403
|
+
const cols = blendStops(
|
|
404
|
+
cells,
|
|
405
|
+
...(stops.length > 0 ? stops : ramps.progress),
|
|
406
|
+
);
|
|
407
|
+
let out = "";
|
|
408
|
+
for (let i = 0; i < cells; i++) {
|
|
409
|
+
out += i < filled ? fg(cols[i], FILL) : fg(TRACK_COLOR(), TRACK);
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** One share of a stacked bar, in one semantic colour. */
|
|
415
|
+
export interface BarSegment {
|
|
416
|
+
value: number;
|
|
417
|
+
color: string;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Stacked bar for a category distribution β the counts ARE the shape.
|
|
422
|
+
*
|
|
423
|
+
* Exactly `cells` wide, always. Two rules fight for that width and the order
|
|
424
|
+
* between them is the whole design:
|
|
425
|
+
*
|
|
426
|
+
* 1. Every non-zero segment gets at least one cell, so one outlier in a large
|
|
427
|
+
* set stays visible instead of rounding to nothing.
|
|
428
|
+
* 2. The row is never wider than `cells`.
|
|
429
|
+
*
|
|
430
|
+
* Rule 2 wins. When there are more non-zero segments than cells, rule 1 is
|
|
431
|
+
* impossible β the earlier version tried anyway: its shrink loop gave up once
|
|
432
|
+
* every segment was down to one cell and returned a row WIDER than asked for
|
|
433
|
+
* (measured: 5 cells for `cells = 3` with five segments), silently breaking the
|
|
434
|
+
* alignment every caller depends on. Now the largest segments keep their cell
|
|
435
|
+
* and the smallest are dropped, which is lossy but bounded and visible.
|
|
436
|
+
*/
|
|
437
|
+
export function stackedBar(cells: number, segments: BarSegment[]): string {
|
|
438
|
+
if (cells <= 0) return "";
|
|
439
|
+
let live = segments.filter((s) => s.value > 0);
|
|
440
|
+
if (live.length === 0) return fg(TRACK_COLOR(), TRACK.repeat(cells));
|
|
441
|
+
// More segments than cells: keep the biggest `cells` of them, in place.
|
|
442
|
+
if (live.length > cells) {
|
|
443
|
+
const cutoff = [...live]
|
|
444
|
+
.sort((a, b) => b.value - a.value)
|
|
445
|
+
.slice(0, cells)
|
|
446
|
+
.reduce((min, s) => Math.min(min, s.value), Number.POSITIVE_INFINITY);
|
|
447
|
+
const kept: BarSegment[] = [];
|
|
448
|
+
for (const s of live) {
|
|
449
|
+
if (s.value >= cutoff && kept.length < cells) kept.push(s);
|
|
450
|
+
}
|
|
451
|
+
live = kept;
|
|
452
|
+
}
|
|
453
|
+
const total = live.reduce((n, s) => n + s.value, 0);
|
|
454
|
+
const widths = live.map((s) =>
|
|
455
|
+
Math.max(1, Math.floor((s.value / total) * cells)),
|
|
456
|
+
);
|
|
457
|
+
let drift = cells - widths.reduce((n, w) => n + w, 0);
|
|
458
|
+
while (drift !== 0) {
|
|
459
|
+
const i = widths.indexOf(Math.max(...widths));
|
|
460
|
+
if (drift > 0) {
|
|
461
|
+
widths[i]++;
|
|
462
|
+
drift--;
|
|
463
|
+
} else if (widths[i] > 1) {
|
|
464
|
+
widths[i]--;
|
|
465
|
+
drift++;
|
|
466
|
+
} else break;
|
|
467
|
+
}
|
|
468
|
+
return live.map((s, i) => fg(s.color, FILL.repeat(widths[i]))).join("");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Indeterminate progress: a comet sweeping a dim track, `cells` wide.
|
|
473
|
+
*
|
|
474
|
+
* The honest widget for work with no denominator β a catalog read, a git fetch
|
|
475
|
+
* whose object count nobody exposes. A determinate meter would have to invent a
|
|
476
|
+
* percentage, and an invented percentage that sticks at 90% is worse than no
|
|
477
|
+
* bar at all. The head is full `hue`, the tail fades toward black over `band`
|
|
478
|
+
* cells, and the whole thing wraps every `cells + band` ticks.
|
|
479
|
+
*/
|
|
480
|
+
export function sweep(
|
|
481
|
+
cells: number,
|
|
482
|
+
tick: number,
|
|
483
|
+
hue: string,
|
|
484
|
+
band = 5,
|
|
485
|
+
): string {
|
|
486
|
+
if (cells <= 0) return "";
|
|
487
|
+
const period = cells + band;
|
|
488
|
+
const head = ((Math.trunc(tick) % period) + period) % period;
|
|
489
|
+
let out = "";
|
|
490
|
+
for (let i = 0; i < cells; i++) {
|
|
491
|
+
const behind = head - i;
|
|
492
|
+
const heat = behind >= 0 && behind < band ? 1 - behind / band : 0;
|
|
493
|
+
out +=
|
|
494
|
+
heat <= 0
|
|
495
|
+
? fg(TRACK_COLOR(), TRACK)
|
|
496
|
+
: fg(mix(darken(hue, 0.75), hue, heat), FILL);
|
|
497
|
+
}
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A discrete status, as dark-or-white ink on a saturated fill. */
|
|
502
|
+
export function badge(label: string, hex: string, ink?: string): string {
|
|
503
|
+
// `inkOn` picks black or white by luminance, which is right for a saturated
|
|
504
|
+
// fill and blunt for a pale one: a soft ochre chip reads better with the deep
|
|
505
|
+
// member of its own family than with flat black. A caller carrying a measured
|
|
506
|
+
// pair passes it; everyone else keeps the automatic choice.
|
|
507
|
+
return on(hex, ink ?? inkOn(hex), ` ${label} `);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Braille spinner. Ten frames, so a 12 fps repaint cycles in under a second:
|
|
512
|
+
* fast enough to read as motion, slow enough not to strobe.
|
|
513
|
+
*/
|
|
514
|
+
const SPINNER = ["β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β "] as const;
|
|
515
|
+
|
|
516
|
+
export function spinner(tick: number): string {
|
|
517
|
+
return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Elapsed time sized to its magnitude: `340ms`, `1.4s`, `2m04s`. */
|
|
521
|
+
export function elapsed(ms: number): string {
|
|
522
|
+
if (!Number.isFinite(ms) || ms < 0) return "--";
|
|
523
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
524
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
525
|
+
const m = Math.floor(ms / 60_000);
|
|
526
|
+
const s = Math.round((ms - m * 60_000) / 1000);
|
|
527
|
+
return `${m}m${String(s).padStart(2, "0")}s`;
|
|
528
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest bootstrap β the "this repo has no profiles yet" path.
|
|
3
|
+
*
|
|
4
|
+
* `install` and `update` both require `.claude/profiles.json`. Rather than
|
|
5
|
+
* giving each a manifest-free fallback (two behaviours to keep in step, and a
|
|
6
|
+
* repo that never gets a committed manifest), a repo without one is offered
|
|
7
|
+
* adoption: magus writes the manifest that describes the project as it
|
|
8
|
+
* stands, then the original command proceeds against it normally.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
getManifestPath,
|
|
13
|
+
readManifest,
|
|
14
|
+
writeManifest,
|
|
15
|
+
} from "../services/manifest.js";
|
|
16
|
+
import {
|
|
17
|
+
type AdoptResult,
|
|
18
|
+
adoptCurrentProject,
|
|
19
|
+
} from "../services/profile-adopt.js";
|
|
20
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
21
|
+
import { confirm } from "./prompt.js";
|
|
22
|
+
|
|
23
|
+
/** Print what adoption would capture, so the user can judge it before writing. */
|
|
24
|
+
function printAdoptionPlan(result: AdoptResult): void {
|
|
25
|
+
console.log(
|
|
26
|
+
"\nThis project has no .claude/profiles.json β magus can create one from its current setup.\n",
|
|
27
|
+
);
|
|
28
|
+
console.log(` profile ${result.profileId} (${result.entry.name})`);
|
|
29
|
+
console.log(
|
|
30
|
+
` plugins ${result.counts.plugins} enabled, adopted as "latest"`,
|
|
31
|
+
);
|
|
32
|
+
if (result.source === "user") {
|
|
33
|
+
// Never let this be invisible: the generated file is meant to be committed,
|
|
34
|
+
// and these came from ~/.claude/settings.json β this machine's personal
|
|
35
|
+
// global set, not anything this repo chose.
|
|
36
|
+
console.log(
|
|
37
|
+
" β read from ~/.claude/settings.json (user scope) β this project",
|
|
38
|
+
);
|
|
39
|
+
console.log(
|
|
40
|
+
" enables none of its own. Review before committing.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (result.counts.mcpServers > 0) {
|
|
44
|
+
console.log(` mcp servers ${result.counts.mcpServers} from .mcp.json`);
|
|
45
|
+
}
|
|
46
|
+
if (result.counts.settingsKeys > 0) {
|
|
47
|
+
console.log(
|
|
48
|
+
` settings ${result.counts.settingsKeys} keys carried over`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
for (const id of result.skipped) {
|
|
52
|
+
console.log(
|
|
53
|
+
` β skipped ${id} β no marketplace in its id, not reproducible`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
for (const hit of result.machinePaths) {
|
|
57
|
+
console.log(` β machine path ${hit}`);
|
|
58
|
+
}
|
|
59
|
+
if (result.machinePaths.length > 0) {
|
|
60
|
+
console.log(
|
|
61
|
+
" Those name a directory on THIS machine and will not resolve on another.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
console.log(
|
|
65
|
+
"\nCommit the file to share this setup with the team; edit it to pin versions.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EnsureManifestOptions {
|
|
70
|
+
/** Read-only mode: never write, just report that adoption is needed. */
|
|
71
|
+
check?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Adopt without prompting. Set only by `magus profile init`, where
|
|
74
|
+
* creating the manifest IS the command the user asked for.
|
|
75
|
+
*/
|
|
76
|
+
allowAdopt?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return the project's manifest, creating one by adoption if it has none.
|
|
81
|
+
*
|
|
82
|
+
* Resolves to null when there is no manifest and none was created β the caller
|
|
83
|
+
* should exit non-zero. Anything it prints has already explained why.
|
|
84
|
+
*/
|
|
85
|
+
export async function ensureManifest(
|
|
86
|
+
projectPath: string,
|
|
87
|
+
opts: EnsureManifestOptions = {},
|
|
88
|
+
): Promise<ProfileManifest | null> {
|
|
89
|
+
const manifest = await readManifest(projectPath);
|
|
90
|
+
if (Object.keys(manifest.profiles).length > 0) return manifest;
|
|
91
|
+
|
|
92
|
+
const result = await adoptCurrentProject(projectPath);
|
|
93
|
+
|
|
94
|
+
if (result.counts.plugins === 0) {
|
|
95
|
+
console.error(
|
|
96
|
+
`No .claude/profiles.json in ${projectPath}, and no enabled plugins to adopt one from.`,
|
|
97
|
+
);
|
|
98
|
+
console.error(
|
|
99
|
+
"Open magus and select the plugins this project needs, then re-run.",
|
|
100
|
+
);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
printAdoptionPlan(result);
|
|
105
|
+
|
|
106
|
+
if (opts.check) {
|
|
107
|
+
console.error(
|
|
108
|
+
"\n--check is read-only and will not create the manifest. Run `magus install` once to adopt.",
|
|
109
|
+
);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `--yes` alone is NOT consent to author this file. It means "do not ask me
|
|
114
|
+
// about the thing I invoked", and someone running `magus update --yes` in
|
|
115
|
+
// CI invoked an update, not the creation of their team's committed contract β
|
|
116
|
+
// which this would otherwise write unreviewed, possibly from the machine's
|
|
117
|
+
// user-scope plugin set. Creating it needs either a human at the prompt or
|
|
118
|
+
// the command whose whole purpose is to create it.
|
|
119
|
+
if (opts.allowAdopt) {
|
|
120
|
+
// fall through to the write
|
|
121
|
+
} else if (!process.stdin.isTTY) {
|
|
122
|
+
console.error(
|
|
123
|
+
"\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
|
|
124
|
+
);
|
|
125
|
+
return null;
|
|
126
|
+
} else if (!(await confirm("\nCreate .claude/profiles.json?"))) {
|
|
127
|
+
console.log("Aborted β nothing written.");
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await writeManifest(result.manifest, projectPath);
|
|
132
|
+
console.log(`β Wrote ${getManifestPath(projectPath)}\n`);
|
|
133
|
+
return result.manifest;
|
|
134
|
+
}
|